diff --git a/Cargo.lock b/Cargo.lock index 8f724f9c91..edddf6a8eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -426,6 +426,7 @@ name = "aprender-ci-tools" version = "0.71.3" dependencies = [ "aprender-build-sha", + "aprender-rollup", "clap", "regex", "serde", @@ -1388,6 +1389,13 @@ dependencies = [ "zstd", ] +[[package]] +name = "aprender-rollup" +version = "0.71.3" +dependencies = [ + "serde_json", +] + [[package]] name = "aprender-serve" version = "0.71.3" diff --git a/Cargo.toml b/Cargo.toml index 7a2a0fd787..12f2b08d7c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ members = [ "crates/aprender-compute-xtask", # --- PY-PORT (C301): CI helpers ported from scripts/**/*.py, publish = false --- "crates/aprender-ci-tools", + "crates/aprender-rollup", # --- APR-MONO Phase 2b: was provable-contracts --- "crates/aprender-contracts", "crates/aprender-contracts-macros", @@ -321,6 +322,7 @@ aprender-train-distill = { path = "crates/aprender-train-distill", version = "0. aprender-orchestrate = { path = "crates/aprender-orchestrate", version = "0.71.3" } aprender-contracts = { path = "crates/aprender-contracts", version = "0.71.3" } aprender-build-sha = { path = "crates/aprender-build-sha", version = "0.71.3" } +aprender-rollup = { path = "crates/aprender-rollup", version = "0.71.3" } aprender-profile = { path = "crates/aprender-profile", version = "0.71.3" } aprender-profile-core = { path = "crates/aprender-profile-core", version = "0.71.3" } aprender-zram-core = { path = "crates/aprender-zram-core", version = "0.71.3" } diff --git a/contracts/binary-debt-v1.yaml b/contracts/binary-debt-v1.yaml index 20d798ae7a..3a081ab34c 100644 --- a/contracts/binary-debt-v1.yaml +++ b/contracts/binary-debt-v1.yaml @@ -111,6 +111,7 @@ binaries: - {crate: aprender, bin: apr, class: KEEP, reason: "the one entry point", target: null} - {crate: aprender-compute-xtask, bin: aprender-compute-xtask, class: KEEP, reason: "build tooling, never installed", target: null} - {crate: aprender-ci-tools, bin: aprender-ci-tools, class: KEEP, reason: "CI tooling ported from scripts/**/*.py (C301), never installed", target: null} + - {crate: aprender-ci-tools, bin: aprender-rollup, class: KEEP, reason: "rollup bot plan/build tooling (T-rollup, #5737), never installed", target: null} - {crate: aprender-contracts-cli, bin: pv, class: KEEP, reason: "contract CLI, dogfooded by every gate", target: null} - {crate: aprender-contracts-cli, bin: pv-sat, class: KEEP, reason: "ONT-5 SHACL reasoner (added after the census); gates call it as its own ELF. Folding it into `pv sat` is a cop ruling, and would count as debt", target: null} - {crate: aprender-zram-generator, bin: aprender-zram-generator, class: KEEP, reason: "systemd generator, must be a standalone ELF", target: null} diff --git a/contracts/contracts.nt b/contracts/contracts.nt index aef57b58ef..81a4534709 100644 --- a/contracts/contracts.nt +++ b/contracts/contracts.nt @@ -872,6 +872,20 @@ "aprender-ci-tools"^^ . "2ffcc380b81bf4779669ef921b1f2607d68308693adb5fe1cafbd5cc5beb2574"^^ . "aprender-ci-tools 0.70.0 (8b7d6a04c5)"^^ . + . + . + . + "acde1c41ec2a53a4783464a72f12ac097702256c"^^ . + "true"^^ . + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"^^ . + "aprender-rollup"^^ . + "aprender-ci-tools"^^ . + "1a8d68713c3c2cdbf8a416eef593606e234d0687eace936e7c23159787c8f312"^^ . + "crates/aprender-ci-tools/src/bin/aprender-rollup.rs"^^ . + "aprender-rollup"^^ . + "acde1c41e"^^ . + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"^^ . + ""^^ . . . . diff --git a/crates/aprender-ci-tools/Cargo.toml b/crates/aprender-ci-tools/Cargo.toml index dbfabc85f1..0ea79ad78f 100644 --- a/crates/aprender-ci-tools/Cargo.toml +++ b/crates/aprender-ci-tools/Cargo.toml @@ -11,6 +11,7 @@ authors = ["PAIML Engineering"] publish = false [dependencies] +aprender-rollup = { workspace = true } clap = { workspace = true } regex = { workspace = true } serde = { workspace = true } @@ -26,3 +27,13 @@ aprender-build-sha = { workspace = true } # #4219: APR_GIT_SHA for --version [lints] workspace = true + +# The rollup CLI ships here, not as a bin of aprender-rollup (FALSIFY-MONO-011: the +# [[bin]] allowlist is shrink-only, and this package is already on it). +[[bin]] +name = "aprender-ci-tools" +path = "src/main.rs" + +[[bin]] +name = "aprender-rollup" +path = "src/bin/aprender-rollup.rs" diff --git a/crates/aprender-ci-tools/src/bin/aprender-rollup.rs b/crates/aprender-ci-tools/src/bin/aprender-rollup.rs new file mode 100644 index 0000000000..4ea93d258e --- /dev/null +++ b/crates/aprender-ci-tools/src/bin/aprender-rollup.rs @@ -0,0 +1,174 @@ +//! `aprender-rollup plan` prints the rollup it would build (read-only). +//! `aprender-rollup build ` assembles it locally in worktree ``; it never pushes or closes. + +use aprender_rollup::mg::{self, Verdict}; +use aprender_rollup::{build, close, gh_json, outcome, report, select}; +use std::process::{Command, ExitCode}; + +fn candidates() -> Result, String> { + let o = Command::new("gh") + .args([ + "pr", + "list", + "--state", + "open", + "--limit", + "100", + "--json", + gh_json::GH_FIELDS, + ]) + .output() + .map_err(|e| format!("cannot run gh: {e}"))?; + if !o.status.success() { + return Err(format!( + "gh failed: {}", + String::from_utf8_lossy(&o.stderr).trim() + )); + } + gh_json::parse_prs(&String::from_utf8_lossy(&o.stdout)) +} + +/// Trimmed stdout of a successful `git` run in `dir`. +fn git_out(dir: &str, args: &[String]) -> Option { + let o = Command::new("git") + .arg("-C") + .arg(dir) + .args(args) + .output() + .ok()?; + o.status + .success() + .then(|| String::from_utf8_lossy(&o.stdout).trim().to_string()) +} + +fn git(dir: &str, args: &[String]) -> bool { + Command::new("git") + .arg("-C") + .arg(dir) + .args(args) + .status() + .is_ok_and(|s| s.success()) +} + +/// Merge each picked PR; a conflicting PR is aborted and ejected (stays open). +fn assemble(dir: &str, picked: &[u64], heads: &[String]) -> Vec { + let mut merged = Vec::new(); + for (n, want) in picked.iter().zip(heads) { + let one = build::steps("main", &[*n]); + // steps 2..4 are this PR's fetch + merge; branch setup already ran for the full set + if !git(dir, &one[2]) || git_out(dir, &build::head_step()).as_deref() != Some(want.as_str()) + { + eprintln!("eject #{n}: head moved since it was judged green and approved"); + } else if git(dir, &one[3]) { + merged.push(*n); + } else { + let _ = git(dir, &build::abort_step()); + eprintln!("eject #{n}: conflicts with the rollup so far"); + } + } + merged +} + +fn run(cmd: &str, dir: Option<&str>) -> Result<(), String> { + let sel = select(&candidates()?); + if cmd == "plan" { + print!("{}", report::plan_text(&sel)); + return Ok(()); + } + let dir = dir.ok_or("usage: aprender-rollup build ")?; + let setup = build::steps("main", &sel.picked); + if !git(dir, &setup[0]) || !git(dir, &setup[1]) { + return Err("cannot create rollup branch".to_string()); + } + let merged = assemble(dir, &sel.picked, &sel.heads); + println!( + "{}\n{}", + build::branch_name(&sel.picked), + build::message(&merged) + ); + Ok(()) +} + +fn gh(args: &[String]) -> Result { + let o = Command::new("gh") + .args(args) + .output() + .map_err(|e| format!("cannot run gh: {e}"))?; + if !o.status.success() { + return Err(format!( + "gh failed: {}", + String::from_utf8_lossy(&o.stderr).trim() + )); + } + Ok(String::from_utf8_lossy(&o.stdout).into_owned()) +} + +/// `verify [--apply]`: read the merge-group verdict for the +/// rollup branch. Green + SHA on main closes each constituent with the SHA; red prints the +/// two halves to rebuild and test; pending touches nothing. Without `--apply`, dry run. +fn verify(a: &[String]) -> Result<(), String> { + let [branch, head, sha, list, rest @ ..] = a else { + return Err("usage: aprender-rollup verify [--apply]".into()); + }; + let picked: Vec = list + .split(',') + .map(|s| { + s.trim_start_matches('#') + .parse() + .map_err(|_| format!("bad PR {s}")) + }) + .collect::>()?; + let runs = gh(&mg::run_list_args(branch))?; + let v = mg::parse_verdict(&runs, head)?; + println!("verdict {v:?}"); + let on_main = |s: &str| git_ok(&mg::ancestor_args(s)); + let sha = (sha != "-" && on_main(sha)).then_some(sha.as_str()); + match v { + Verdict::Pending => {} + Verdict::Red => match aprender_rollup::split(&picked) { + Some((x, y)) => println!("bisect: rebuild halves {x:?} and {y:?}"), + None => println!("culprit #{}", picked[0]), + }, + Verdict::Green if sha.is_none() => { + println!("green, waiting for the rollup commit on origin/main; nothing posted"); + } + Verdict::Green => { + for n in &picked { + let args = close::gh_args(*n, &outcome(true, sha)); + println!("#{n}: gh {}", args.join(" ")); + if rest.iter().any(|r| r == "--apply") { + gh(&args)?; + } + } + } + } + Ok(()) +} + +/// Run `git` in the current checkout; true on exit 0. +fn git_ok(args: &[String]) -> bool { + Command::new("git") + .args(args) + .status() + .is_ok_and(|s| s.success()) +} + +fn main() -> ExitCode { + let args: Vec = std::env::args().collect(); + let cmd = args.get(1).map(String::as_str).unwrap_or(""); + let res = match cmd { + "plan" | "build" => run(cmd, args.get(2).map(String::as_str)), + "verify" => verify(&args[2..]), + _ => { + eprintln!("usage: aprender-rollup plan | build | verify [--apply]"); + return ExitCode::from(2); + } + }; + match res { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("not_measured: {e}"); + ExitCode::FAILURE + } + } +} diff --git a/crates/aprender-rollup/Cargo.toml b/crates/aprender-rollup/Cargo.toml new file mode 100644 index 0000000000..50b49c7914 --- /dev/null +++ b/crates/aprender-rollup/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "aprender-rollup" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +description = "Rollup bot core: picks small green PRs to land as one merge-queue entry (T-rollup, #5394)" +license = "MIT OR Apache-2.0" +repository = "https://github.com/paiml/aprender" +authors = ["PAIML Engineering"] +# Build tooling, never installed or published. +publish = false + +[dependencies] +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/crates/aprender-rollup/README.md b/crates/aprender-rollup/README.md new file mode 100644 index 0000000000..a377d4381c --- /dev/null +++ b/crates/aprender-rollup/README.md @@ -0,0 +1,12 @@ +# aprender-rollup + +Rollup bot core: picks small green PRs to land as one merge-queue entry +(T-rollup, #5394, PMAT-5737). Part of the [paiml/aprender](https://github.com/paiml/aprender) +monorepo. `publish = false`, build tooling, never installed. This crate is library-only; the CLI +ships as the `aprender-rollup` bin of `aprender-ci-tools` (`cargo run -p aprender-ci-tools --bin aprender-rollup -- plan`), +so the shrink-only [[bin]] allowlist (FALSIFY-MONO-011) does not grow. + +| Subcommand | Effect | +|------------|--------| +| `aprender-rollup plan` | prints the rollup it would build (read-only) | +| `aprender-rollup build ` | assembles the rollup locally in worktree ``; never pushes or closes | diff --git a/crates/aprender-rollup/src/build.rs b/crates/aprender-rollup/src/build.rs new file mode 100644 index 0000000000..4975458d89 --- /dev/null +++ b/crates/aprender-rollup/src/build.rs @@ -0,0 +1,56 @@ +//! Git steps that assemble a rollup branch from picked PRs (pure; `main.rs` runs them). + +/// Branch name for a rollup of `picked` (lowest PR number first, so it is stable). +#[must_use] +pub fn branch_name(picked: &[u64]) -> String { + let first = picked.iter().min().copied().unwrap_or(0); + format!("rollup/{first}-{}", picked.len()) +} + +/// Commit message: constituents are named so the bot can close them after the SHA lands. +#[must_use] +pub fn message(picked: &[u64]) -> String { + let list: Vec = picked.iter().map(|n| format!("#{n}")).collect(); + format!("Rollup of {} PRs: {}", picked.len(), list.join(" ")) +} + +/// Ordered git argument lists: branch from `base`, then fetch and merge each PR head. +#[must_use] +pub fn steps(base: &str, picked: &[u64]) -> Vec> { + let v = |a: &[&str]| a.iter().map(|s| (*s).to_string()).collect::>(); + let mut out = vec![ + v(&["fetch", "origin", base]), + v(&[ + "checkout", + "-B", + &branch_name(picked), + &format!("origin/{base}"), + ]), + ]; + for n in picked { + out.push(v(&["fetch", "origin", &format!("pull/{n}/head")])); + out.push(v(&[ + "merge", + "--no-ff", + "-m", + &format!("Merge #{n}"), + "FETCH_HEAD", + ])); + } + out +} + +/// Print the commit the last `fetch` brought in, to compare with the head judged at selection. +#[must_use] +pub fn head_step() -> Vec { + vec!["rev-parse".to_string(), "FETCH_HEAD".to_string()] +} + +/// Recovery step after a merge conflict: the PR is ejected and stays open. +#[must_use] +pub fn abort_step() -> Vec { + vec!["merge".to_string(), "--abort".to_string()] +} + +#[cfg(test)] +mod tests; diff --git a/crates/aprender-rollup/src/build/tests.rs b/crates/aprender-rollup/src/build/tests.rs new file mode 100644 index 0000000000..647f094d66 --- /dev/null +++ b/crates/aprender-rollup/src/build/tests.rs @@ -0,0 +1,32 @@ +use super::*; + +#[test] +fn branch_name_uses_lowest_number_and_count() { + assert_eq!(branch_name(&[7, 3, 9]), "rollup/3-3"); +} + +#[test] +fn message_names_every_constituent() { + assert_eq!(message(&[3, 7]), "Rollup of 2 PRs: #3 #7"); +} + +#[test] +fn steps_branch_from_base_then_fetch_and_merge_each() { + let s = steps("main", &[3, 7]); + assert_eq!(s[0], ["fetch", "origin", "main"]); + assert_eq!(s[1], ["checkout", "-B", "rollup/3-2", "origin/main"]); + assert_eq!(s[2], ["fetch", "origin", "pull/3/head"]); + assert_eq!(s[3], ["merge", "--no-ff", "-m", "Merge #3", "FETCH_HEAD"]); + assert_eq!(s[4][2], "pull/7/head"); + assert_eq!(s.len(), 6); +} + +#[test] +fn abort_is_merge_abort() { + assert_eq!(abort_step(), ["merge", "--abort"]); +} + +#[test] +fn head_check_step_reads_fetch_head() { + assert_eq!(head_step(), ["rev-parse", "FETCH_HEAD"]); +} diff --git a/crates/aprender-rollup/src/close.rs b/crates/aprender-rollup/src/close.rs new file mode 100644 index 0000000000..8f16429d8e --- /dev/null +++ b/crates/aprender-rollup/src/close.rs @@ -0,0 +1,29 @@ +//! `gh` argument lists for the only two things the bot does to a constituent PR. + +use crate::Outcome; + +/// Comment-and-close args for a constituent, or a reasoned comment if it stays open. +/// Closing needs a non-empty SHA on main; `Outcome` already guarantees that. +#[must_use] +pub fn gh_args(pr: u64, outcome: &Outcome) -> Vec { + let n = pr.to_string(); + match outcome { + Outcome::Close { sha } => { + let body = format!("Landed in rollup commit {sha} on main."); + ["pr", "close", &n, "--comment", &body] + .iter() + .map(|s| (*s).to_string()) + .collect() + } + Outcome::KeepOpen { reason } => { + let body = format!("Ejected from rollup, left open: {reason}"); + ["pr", "comment", &n, "--body", &body] + .iter() + .map(|s| (*s).to_string()) + .collect() + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/aprender-rollup/src/close/tests.rs b/crates/aprender-rollup/src/close/tests.rs new file mode 100644 index 0000000000..64c39aa6ec --- /dev/null +++ b/crates/aprender-rollup/src/close/tests.rs @@ -0,0 +1,26 @@ +use super::*; + +#[test] +fn close_posts_the_sha() { + let a = gh_args( + 12, + &Outcome::Close { + sha: "abc123".to_string(), + }, + ); + assert_eq!(a[..3], ["pr", "close", "12"]); + assert!(a.last().is_some_and(|b| b.contains("abc123"))); +} + +#[test] +fn keep_open_never_closes_and_gives_reason() { + let a = gh_args( + 12, + &Outcome::KeepOpen { + reason: "red".to_string(), + }, + ); + assert_eq!(a[..3], ["pr", "comment", "12"]); + assert!(!a.iter().any(|s| s == "close")); + assert!(a.last().is_some_and(|b| b.contains("red"))); +} diff --git a/crates/aprender-rollup/src/gh_json.rs b/crates/aprender-rollup/src/gh_json.rs new file mode 100644 index 0000000000..85d325c1bb --- /dev/null +++ b/crates/aprender-rollup/src/gh_json.rs @@ -0,0 +1,64 @@ +//! Parse `gh pr list --json number,additions,deletions,labels,files,reviewDecision,statusCheckRollup` +//! into [`Candidate`]s. Pure: the caller runs `gh` (GraphQL, ETag, GH-1/GH-2) and passes the text. + +use crate::Candidate; +use serde_json::Value; + +/// The `--json` field list the parser expects. +pub const GH_FIELDS: &str = + "number,additions,deletions,labels,files,reviewDecision,statusCheckRollup,headRefOid"; + +fn check_ok(c: &Value) -> bool { + // CheckRun: status COMPLETED + conclusion; StatusContext: state. + match (c["conclusion"].as_str(), c["state"].as_str()) { + (Some(k), _) => matches!(k, "SUCCESS" | "NEUTRAL" | "SKIPPED"), + (None, Some(s)) => s == "SUCCESS", + _ => false, + } +} + +/// A real pass: all-skipped/neutral does not count as green. +fn is_success(c: &Value) -> bool { + c["conclusion"].as_str() == Some("SUCCESS") || c["state"].as_str() == Some("SUCCESS") +} + +fn parse_one(v: &Value) -> Result { + let number = v["number"].as_u64().ok_or("missing number")?; + let n = |k: &str| u32::try_from(v[k].as_u64().unwrap_or(u64::MAX)).unwrap_or(u32::MAX); + let strs = |k: &str, f: &str| -> Vec { + v[k].as_array() + .map(|a| { + a.iter() + .filter_map(|x| x[f].as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() + }; + let checks = v["statusCheckRollup"].as_array(); + Ok(Candidate { + number, + additions: n("additions"), + deletions: n("deletions"), + green: checks.is_some_and(|c| c.iter().all(check_ok) && c.iter().any(is_success)), + approved: v["reviewDecision"].as_str() == Some("APPROVED"), + labels: strs("labels", "name"), + files: strs("files", "path"), + head: v["headRefOid"].as_str().unwrap_or_default().to_string(), + }) +} + +/// Parse a JSON array of PRs. A malformed row fails the whole read (not_measured, L23). +/// +/// # Errors +/// Returns the parse or field error text. +pub fn parse_prs(json: &str) -> Result, String> { + let v: Value = serde_json::from_str(json).map_err(|e| e.to_string())?; + v.as_array() + .ok_or_else(|| "expected a JSON array".to_string())? + .iter() + .map(parse_one) + .collect() +} + +#[cfg(test)] +mod tests; diff --git a/crates/aprender-rollup/src/gh_json/tests.rs b/crates/aprender-rollup/src/gh_json/tests.rs new file mode 100644 index 0000000000..2f4efc8f2b --- /dev/null +++ b/crates/aprender-rollup/src/gh_json/tests.rs @@ -0,0 +1,65 @@ +use super::*; + +const OK: &str = r#"[{"number":7,"additions":20,"deletions":3, + "headRefOid":"abc123","labels":[{"name":"rollup-ok"},{"name":"x"}], + "files":[{"path":"a.rs"},{"path":"b.rs"}], + "reviewDecision":"APPROVED", + "statusCheckRollup":[{"conclusion":"SUCCESS"},{"conclusion":"SKIPPED"},{"state":"SUCCESS"}]}]"#; + +#[test] +fn parses_a_green_approved_pr() { + let c = &parse_prs(OK).expect("parse")[0]; + assert_eq!((c.number, c.additions, c.deletions), (7, 20, 3)); + assert!(c.green && c.approved); + assert_eq!(c.labels, ["rollup-ok", "x"]); + assert_eq!(c.files, ["a.rs", "b.rs"]); + assert_eq!(c.head, "abc123"); +} + +#[test] +fn one_failed_or_pending_check_is_not_green() { + for bad in [ + r#"{"conclusion":"FAILURE"}"#, + r#"{"status":"IN_PROGRESS"}"#, + r#"{"state":"PENDING"}"#, + ] { + let j = OK.replace(r#"{"conclusion":"SKIPPED"}"#, bad); + assert!(!parse_prs(&j).expect("parse")[0].green, "{bad}"); + } +} + +#[test] +fn no_checks_is_not_green() { + let j = OK.replace(r#""statusCheckRollup":[{"conclusion":"SUCCESS"},{"conclusion":"SKIPPED"},{"state":"SUCCESS"}]"#, r#""statusCheckRollup":[]"#); + assert!(!parse_prs(&j).expect("parse")[0].green); +} + +#[test] +fn review_decision_other_than_approved_is_not_approved() { + for d in ["CHANGES_REQUESTED", "REVIEW_REQUIRED", ""] { + let j = OK.replace("APPROVED", d); + assert!(!parse_prs(&j).expect("parse")[0].approved, "{d}"); + } +} + +#[test] +fn missing_line_counts_fail_closed_as_too_large() { + let j = r#"[{"number":1,"labels":[],"files":[],"statusCheckRollup":[]}]"#; + let c = &parse_prs(j).expect("parse")[0]; + assert_eq!(c.additions, u32::MAX); +} + +#[test] +fn malformed_input_is_an_error_not_an_empty_list() { + assert!(parse_prs("not json").is_err()); + assert!(parse_prs("{}").is_err()); + assert!(parse_prs(r#"[{"additions":1}]"#).is_err()); + assert_eq!(parse_prs("[]"), Ok(vec![])); +} + +#[test] +fn only_skipped_checks_are_not_green() { + let j = r#"[{"number":1,"additions":1,"deletions":1,"labels":[],"files":[],"headRefOid":"h", + "reviewDecision":"APPROVED","statusCheckRollup":[{"conclusion":"SKIPPED"},{"conclusion":"NEUTRAL"}]}]"#; + assert!(!parse_prs(j).expect("parse")[0].green); +} diff --git a/crates/aprender-rollup/src/lib.rs b/crates/aprender-rollup/src/lib.rs new file mode 100644 index 0000000000..be82e4a04e --- /dev/null +++ b/crates/aprender-rollup/src/lib.rs @@ -0,0 +1,155 @@ +//! Rollup bot core (T-rollup, refs #5394): pure selection and bisection, no I/O. +//! +//! The `gh` layer feeds [`Candidate`]s in; [`select`] picks a rollup; on a red +//! merge-group run [`split`] halves it. The bot closes a constituent only after the +//! rollup commit is on main, with the SHA posted; never on red (see [`Outcome`]). + +pub mod build; +pub mod close; +pub mod gh_json; +pub mod mg; +pub mod report; + +/// Largest PR (additions + deletions, from `gh`) that may join a rollup. +pub const MAX_LINES: u32 = 200; +/// Most constituents in one rollup (operator: b = 3, only with bisection, until p_red is measured). +pub const MAX_ENTRIES: usize = 3; +/// Label a PR owner sets to opt in. +pub const OPT_IN_LABEL: &str = "rollup-ok"; + +/// One open PR as read from GitHub. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Candidate { + pub number: u64, + pub additions: u32, + pub deletions: u32, + pub green: bool, + pub approved: bool, + pub labels: Vec, + pub files: Vec, + /// Head commit the checks and approval were read at. + pub head: String, +} + +/// Why a PR stays out of a rollup; posted as the ejection reason. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Reject { + NotGreen, + NotApproved, + NoOptInLabel, + TooLarge(u32), + ForbiddenFile(String), + FileOverlap { + with: u64, + file: String, + }, + /// Eligible, but the rollup already holds [`MAX_ENTRIES`]. + OverCap, +} + +/// Files a rollup never carries: CI workflows, contracts, the lockfile. +#[must_use] +pub fn is_forbidden(path: &str) -> bool { + path.starts_with(".github/workflows/") || path.starts_with("contracts/") || path == "Cargo.lock" +} + +/// First reason `c` is ineligible on its own, if any. +#[must_use] +pub fn check(c: &Candidate) -> Option { + if !c.green { + return Some(Reject::NotGreen); + } + if !c.approved { + return Some(Reject::NotApproved); + } + if !c.labels.iter().any(|l| l == OPT_IN_LABEL) { + return Some(Reject::NoOptInLabel); + } + let lines = c.additions.saturating_add(c.deletions); + if lines > MAX_LINES { + return Some(Reject::TooLarge(lines)); + } + c.files + .iter() + .find(|f| is_forbidden(f)) + .map(|f| Reject::ForbiddenFile(f.clone())) +} + +/// A chosen rollup plus every PR left out and why. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct Selection { + pub picked: Vec, + /// Head SHA of each pick (parallel to `picked`), re-checked when the rollup is built. + pub heads: Vec, + pub rejected: Vec<(u64, Reject)>, +} + +/// Pick up to [`MAX_ENTRIES`] eligible PRs, lowest number first, no shared file. +#[must_use] +pub fn select(cands: &[Candidate]) -> Selection { + let mut sorted: Vec<&Candidate> = cands.iter().collect(); + sorted.sort_by_key(|c| c.number); + let mut sel = Selection::default(); + let mut taken: Vec<(&Candidate, u64)> = Vec::new(); + for c in sorted { + if let Some(r) = check(c) { + sel.rejected.push((c.number, r)); + continue; + } + let clash = taken.iter().find_map(|(t, n)| { + c.files + .iter() + .find(|f| t.files.contains(f)) + .map(|f| (*n, f.clone())) + }); + if let Some((with, file)) = clash { + sel.rejected + .push((c.number, Reject::FileOverlap { with, file })); + } else if taken.len() < MAX_ENTRIES { + sel.picked.push(c.number); + sel.heads.push(c.head.clone()); + taken.push((c, c.number)); + } else { + sel.rejected.push((c.number, Reject::OverCap)); + } + } + sel +} + +/// Halve a red rollup for bisection. A single entry cannot be split. +#[must_use] +pub fn split(picked: &[u64]) -> Option<(Vec, Vec)> { + if picked.len() < 2 { + return None; + } + let (a, b) = picked.split_at(picked.len() / 2); + Some((a.to_vec(), b.to_vec())) +} + +/// What the bot may do with one constituent after a merge-group run. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Outcome { + /// Rollup commit is on main: close with the SHA posted. + Close { sha: String }, + /// Red or not yet on main: leave open and say why. + KeepOpen { reason: String }, +} + +/// Close only when the run is green and the rollup commit is on main. +#[must_use] +pub fn outcome(green: bool, sha_on_main: Option<&str>) -> Outcome { + match (green, sha_on_main) { + (true, Some(sha)) if !sha.is_empty() => Outcome::Close { + sha: sha.to_string(), + }, + (false, _) => Outcome::KeepOpen { + reason: "rollup run red".to_string(), + }, + _ => Outcome::KeepOpen { + reason: "rollup commit not on main".to_string(), + }, + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/aprender-rollup/src/mg.rs b/crates/aprender-rollup/src/mg.rs new file mode 100644 index 0000000000..58c3124170 --- /dev/null +++ b/crates/aprender-rollup/src/mg.rs @@ -0,0 +1,132 @@ +//! Merge-group result check, bisection on red, and the close plan (T-rollup, #5394). +//! +//! Pure: the caller runs `gh run list --json status,conclusion` and passes the text, and +//! supplies the "test this subset" step to [`bisect`]. A constituent is closed only when +//! its subset went green and the rollup SHA is on main ([`crate::outcome`]). + +use crate::{close, outcome}; +use serde_json::Value; + +/// State of the merge-group runs for one rollup (or one half of it). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Verdict { + Green, + Red, + Pending, +} + +/// Fold `gh run list --json status,conclusion,headSha` (only runs of `head` count) into one verdict. Any failed run is red; +/// no runs, or any unfinished run with none failed, is pending (never green). +/// +/// # Errors +/// Returns the parse error text (a failed read is not_measured, L23). +pub fn parse_verdict(json: &str, head: &str) -> Result { + let v: Value = serde_json::from_str(json).map_err(|e| e.to_string())?; + let runs: Vec<&Value> = v + .as_array() + .ok_or("expected a JSON array")? + .iter() + .filter(|r| r["headSha"].as_str() == Some(head)) + .collect(); + // Green needs every run finished and at least one real success: all-skipped is not a pass. + let (mut unfinished, mut succeeded) = (false, false); + for r in &runs { + match (r["status"].as_str(), r["conclusion"].as_str()) { + (Some("completed"), Some("success")) => succeeded = true, + (Some("completed"), Some("neutral" | "skipped")) => {} + (Some("completed"), Some(_)) => return Ok(Verdict::Red), + _ => unfinished = true, + } + } + Ok(if succeeded && !unfinished { + Verdict::Green + } else { + Verdict::Pending + }) +} + +/// `gh` args listing the merge-group runs for a rollup branch. +#[must_use] +pub fn run_list_args(branch: &str) -> Vec { + [ + "run", + "list", + "--event", + "merge_group", + "--branch", + branch, + "--json", + "status,conclusion,headSha", + ] + .iter() + .map(|s| (*s).to_string()) + .collect() +} + +/// `git` args: is `sha` an ancestor of `origin/main` (i.e. on main)? +#[must_use] +pub fn ancestor_args(sha: &str) -> Vec { + ["merge-base", "--is-ancestor", sha, "origin/main"] + .iter() + .map(|s| (*s).to_string()) + .collect() +} + +/// Result of bisecting a rollup: who may land, who broke it, who is still unknown. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Bisect { + pub good: Vec, + pub culprits: Vec, + pub undecided: Vec, +} + +/// Halve a red set until each culprit is isolated. `test` runs a subset through the merge +/// group. A green subset is good; a red single entry is a culprit; pending stays undecided. +pub fn bisect(picked: &[u64], test: &mut dyn FnMut(&[u64]) -> Verdict) -> Bisect { + let mut out = Bisect::default(); + walk(picked, test, &mut out); + out.good.sort_unstable(); + out.culprits.sort_unstable(); + out.undecided.sort_unstable(); + out +} + +fn walk(set: &[u64], test: &mut dyn FnMut(&[u64]) -> Verdict, out: &mut Bisect) { + if set.is_empty() { + return; + } + match test(set) { + Verdict::Green => out.good.extend_from_slice(set), + Verdict::Pending => out.undecided.extend_from_slice(set), + Verdict::Red => match crate::split(set) { + None => out.culprits.extend_from_slice(set), + Some((a, b)) => { + walk(&a, test, out); + walk(&b, test, out); + } + }, + } +} + +/// One `gh` action per constituent. Good ones close with the SHA, only when it is on main; +/// culprits and undecided ones get a reasoned comment and stay open. +#[must_use] +pub fn close_plan(b: &Bisect, sha_on_main: Option<&str>) -> Vec<(u64, Vec)> { + let mut plan = Vec::new(); + for n in &b.good { + plan.push((*n, close::gh_args(*n, &outcome(true, sha_on_main)))); + } + for n in &b.culprits { + plan.push((*n, close::gh_args(*n, &outcome(false, sha_on_main)))); + } + for n in &b.undecided { + let o = crate::Outcome::KeepOpen { + reason: "rollup run not finished".to_string(), + }; + plan.push((*n, close::gh_args(*n, &o))); + } + plan +} + +#[cfg(test)] +mod tests; diff --git a/crates/aprender-rollup/src/mg/tests.rs b/crates/aprender-rollup/src/mg/tests.rs new file mode 100644 index 0000000000..4f7bddec40 --- /dev/null +++ b/crates/aprender-rollup/src/mg/tests.rs @@ -0,0 +1,186 @@ +use super::*; + +fn run(status: &str, conclusion: Option<&str>) -> String { + let c = conclusion.map_or("null".to_string(), |c| format!("\"{c}\"")); + format!("{{\"headSha\":\"H\",\"status\":\"{status}\",\"conclusion\":{c}}}") +} + +#[test] +fn verdict_green_needs_every_run_success() { + let j = format!( + "[{},{}]", + run("completed", Some("success")), + run("completed", Some("success")) + ); + assert_eq!(parse_verdict(&j, "H"), Ok(Verdict::Green)); +} + +#[test] +fn verdict_red_if_any_run_failed_even_with_others_pending() { + let j = format!( + "[{},{}]", + run("in_progress", None), + run("completed", Some("failure")) + ); + assert_eq!(parse_verdict(&j, "H"), Ok(Verdict::Red)); +} + +#[test] +fn verdict_pending_when_unfinished_and_none_failed() { + let j = format!( + "[{},{}]", + run("completed", Some("success")), + run("queued", None) + ); + assert_eq!(parse_verdict(&j, "H"), Ok(Verdict::Pending)); +} + +#[test] +fn no_runs_is_pending_not_green() { + assert_eq!(parse_verdict("[]", "H"), Ok(Verdict::Pending)); +} + +#[test] +fn cancelled_and_timed_out_are_red() { + for k in ["cancelled", "timed_out", "startup_failure"] { + let j = format!("[{}]", run("completed", Some(k))); + assert_eq!(parse_verdict(&j, "H"), Ok(Verdict::Red), "{k}"); + } +} + +#[test] +fn malformed_read_is_not_measured() { + assert!(parse_verdict("not json", "H").is_err()); + assert!(parse_verdict("{}", "H").is_err()); +} + +#[test] +fn bisect_all_green_lands_everything() { + let r = bisect(&[1, 2, 3, 4], &mut |_| Verdict::Green); + assert_eq!( + r, + Bisect { + good: vec![1, 2, 3, 4], + culprits: vec![], + undecided: vec![] + } + ); +} + +#[test] +fn bisect_finds_the_single_culprit() { + let r = bisect(&[1, 2, 3, 4, 5], &mut |s| { + if s.contains(&4) { + Verdict::Red + } else { + Verdict::Green + } + }); + assert_eq!(r.culprits, vec![4]); + assert_eq!(r.good, vec![1, 2, 3, 5]); + assert!(r.undecided.is_empty()); +} + +#[test] +fn bisect_single_red_entry_is_a_culprit() { + let r = bisect(&[7], &mut |_| Verdict::Red); + assert_eq!(r.culprits, vec![7]); +} + +#[test] +fn bisect_pending_is_undecided_never_closed() { + let r = bisect(&[1, 2], &mut |_| Verdict::Pending); + assert_eq!(r.undecided, vec![1, 2]); + assert!(r.good.is_empty() && r.culprits.is_empty()); +} + +#[test] +fn bisect_two_culprits_are_both_found() { + let r = bisect(&[1, 2, 3, 4], &mut |s| { + if s.contains(&1) || s.contains(&4) { + Verdict::Red + } else { + Verdict::Green + } + }); + assert_eq!(r.culprits, vec![1, 4]); + assert_eq!(r.good, vec![2, 3]); +} + +#[test] +fn close_plan_closes_good_with_sha_and_comments_culprits() { + let b = Bisect { + good: vec![1, 2], + culprits: vec![3], + undecided: vec![4], + }; + let p = close_plan(&b, Some("abc")); + assert_eq!(p.len(), 4); + assert!(p + .iter() + .any(|(n, a)| *n == 1 && a[1] == "close" && a[4].contains("abc"))); + assert!(p.iter().any(|(n, a)| *n == 3 && a[1] == "comment")); + assert!(p.iter().any(|(n, a)| *n == 4 && a[1] == "comment")); + assert!(!p + .iter() + .any(|(n, a)| (*n == 3 || *n == 4) && a[1] == "close")); +} + +#[test] +fn close_plan_without_sha_on_main_closes_nothing() { + let b = Bisect { + good: vec![1, 2], + culprits: vec![], + undecided: vec![], + }; + let p = close_plan(&b, None); + assert!(p.iter().all(|(_, a)| a[1] == "comment")); +} + +#[test] +fn run_list_args_filter_merge_group_on_the_branch() { + let a = run_list_args("rollup/5-3"); + assert!(a.windows(2).any(|w| w == ["--event", "merge_group"])); + assert!(a.windows(2).any(|w| w == ["--branch", "rollup/5-3"])); +} + +#[test] +fn runs_of_another_head_are_ignored() { + let stale_red = "{\"headSha\":\"OLD\",\"status\":\"completed\",\"conclusion\":\"failure\"}"; + let stale_green = "{\"headSha\":\"OLD\",\"status\":\"completed\",\"conclusion\":\"success\"}"; + assert_eq!( + parse_verdict(&format!("[{stale_red}]"), "H"), + Ok(Verdict::Pending) + ); + assert_eq!( + parse_verdict(&format!("[{stale_green}]"), "H"), + Ok(Verdict::Pending) + ); + let j = format!("[{stale_red},{}]", run("completed", Some("success"))); + assert_eq!(parse_verdict(&j, "H"), Ok(Verdict::Green)); +} + +#[test] +fn run_list_args_ask_for_head_sha() { + assert!(run_list_args("b").iter().any(|a| a.contains("headSha"))); +} + +#[test] +fn ancestor_args_check_against_origin_main() { + assert_eq!( + ancestor_args("abc"), + ["merge-base", "--is-ancestor", "abc", "origin/main"] + ); +} + +#[test] +fn a_run_with_only_skipped_jobs_is_not_green() { + let j = format!("[{}]", run("completed", Some("skipped"))); + assert_eq!(parse_verdict(&j, "H"), Ok(Verdict::Pending)); + let j = format!( + "[{},{}]", + run("completed", Some("skipped")), + run("completed", Some("success")) + ); + assert_eq!(parse_verdict(&j, "H"), Ok(Verdict::Green)); +} diff --git a/crates/aprender-rollup/src/report.rs b/crates/aprender-rollup/src/report.rs new file mode 100644 index 0000000000..21b4565da7 --- /dev/null +++ b/crates/aprender-rollup/src/report.rs @@ -0,0 +1,29 @@ +//! Human/machine text for a rollup plan (what `rollup-bot plan` prints). + +use crate::{Reject, Selection}; + +fn reason(r: &Reject) -> String { + match r { + Reject::NotGreen => "checks not all green".to_string(), + Reject::NotApproved => "not approved".to_string(), + Reject::NoOptInLabel => format!("missing label {}", crate::OPT_IN_LABEL), + Reject::TooLarge(n) => format!("{n} lines > {}", crate::MAX_LINES), + Reject::ForbiddenFile(f) => format!("touches {f} (CI, contract or lockfile)"), + Reject::FileOverlap { with, file } => format!("shares {file} with #{with}"), + Reject::OverCap => format!("rollup already holds {}", crate::MAX_ENTRIES), + } +} + +/// One line for the rollup, then one `skip` line per rejected PR. +#[must_use] +pub fn plan_text(sel: &Selection) -> String { + let picked: Vec = sel.picked.iter().map(|n| format!("#{n}")).collect(); + let mut out = format!("rollup {} [{}]\n", sel.picked.len(), picked.join(" ")); + for (n, r) in &sel.rejected { + out.push_str(&format!("skip #{n}: {}\n", reason(r))); + } + out +} + +#[cfg(test)] +mod tests; diff --git a/crates/aprender-rollup/src/report/tests.rs b/crates/aprender-rollup/src/report/tests.rs new file mode 100644 index 0000000000..f53cac75dc --- /dev/null +++ b/crates/aprender-rollup/src/report/tests.rs @@ -0,0 +1,48 @@ +use super::*; + +#[test] +fn empty_plan_is_one_line() { + assert_eq!(plan_text(&Selection::default()), "rollup 0 []\n"); +} + +#[test] +fn lists_picked_then_each_skip_with_its_reason() { + let sel = Selection { + heads: vec![], + picked: vec![1, 3], + rejected: vec![ + ( + 2, + Reject::FileOverlap { + with: 1, + file: "a.rs".to_string(), + }, + ), + (4, Reject::TooLarge(201)), + (5, Reject::NotGreen), + (6, Reject::NotApproved), + (7, Reject::NoOptInLabel), + (8, Reject::ForbiddenFile("Cargo.lock".to_string())), + ], + }; + assert_eq!( + plan_text(&sel), + "rollup 2 [#1 #3]\n\ + skip #2: shares a.rs with #1\n\ + skip #4: 201 lines > 200\n\ + skip #5: checks not all green\n\ + skip #6: not approved\n\ + skip #7: missing label rollup-ok\n\ + skip #8: touches Cargo.lock (CI, contract or lockfile)\n" + ); +} + +#[test] +fn over_cap_is_named_in_the_plan() { + let sel = Selection { + heads: vec![], + picked: vec![1], + rejected: vec![(9, Reject::OverCap)], + }; + assert!(plan_text(&sel).contains("skip #9: rollup already holds")); +} diff --git a/crates/aprender-rollup/src/tests.rs b/crates/aprender-rollup/src/tests.rs new file mode 100644 index 0000000000..4aae208abe --- /dev/null +++ b/crates/aprender-rollup/src/tests.rs @@ -0,0 +1,125 @@ +use super::*; + +fn cand(n: u64, files: &[&str]) -> Candidate { + Candidate { + number: n, + additions: 10, + deletions: 5, + green: true, + approved: true, + labels: vec![OPT_IN_LABEL.to_string()], + files: files.iter().map(|s| (*s).to_string()).collect(), + head: format!("sha{n}"), + } +} + +#[test] +fn eligible_candidate_passes() { + assert_eq!(check(&cand(1, &["a.rs"])), None); +} + +#[test] +fn each_gate_rejects() { + let mut c = cand(1, &["a.rs"]); + c.green = false; + assert_eq!(check(&c), Some(Reject::NotGreen)); + let mut c = cand(1, &["a.rs"]); + c.approved = false; + assert_eq!(check(&c), Some(Reject::NotApproved)); + let mut c = cand(1, &["a.rs"]); + c.labels.clear(); + assert_eq!(check(&c), Some(Reject::NoOptInLabel)); +} + +#[test] +fn line_limit_is_additions_plus_deletions_inclusive() { + let mut c = cand(1, &["a.rs"]); + c.additions = 150; + c.deletions = 50; + assert_eq!(check(&c), None); + c.deletions = 51; + assert_eq!(check(&c), Some(Reject::TooLarge(201))); + c.additions = u32::MAX; + assert_eq!(check(&c), Some(Reject::TooLarge(u32::MAX))); +} + +#[test] +fn forbidden_paths() { + for p in [".github/workflows/ci.yml", "contracts/x.yaml", "Cargo.lock"] { + assert_eq!( + check(&cand(1, &["a.rs", p])), + Some(Reject::ForbiddenFile(p.to_string())) + ); + } + assert!(!is_forbidden("crates/x/Cargo.lock.rs")); + assert!(!is_forbidden("docs/contracts/x.md")); +} + +#[test] +fn select_orders_by_number_and_skips_overlap() { + let sel = select(&[ + cand(3, &["b.rs"]), + cand(1, &["a.rs"]), + cand(2, &["a.rs", "c.rs"]), + ]); + assert_eq!(sel.picked, vec![1, 3]); + assert_eq!( + sel.rejected, + vec![( + 2, + Reject::FileOverlap { + with: 1, + file: "a.rs".to_string() + } + )] + ); +} + +#[test] +fn select_caps_at_three_and_names_the_rest() { + let cands: Vec = (1..=12).map(|n| cand(n, &[&format!("f{n}.rs")])).collect(); + let sel = select(&cands); + assert_eq!(sel.picked, (1..=3).collect::>()); + assert_eq!(sel.rejected.len(), 9); + assert!(sel.rejected.iter().all(|(_, r)| *r == Reject::OverCap)); +} + +#[test] +fn ineligible_never_blocks_later_candidates() { + let mut bad = cand(1, &["a.rs"]); + bad.green = false; + let sel = select(&[bad, cand(2, &["a.rs"])]); + assert_eq!(sel.picked, vec![2]); +} + +#[test] +fn split_halves_and_stops_at_one() { + assert_eq!(split(&[1, 2, 3, 4]), Some((vec![1, 2], vec![3, 4]))); + assert_eq!(split(&[1, 2, 3]), Some((vec![1], vec![2, 3]))); + assert_eq!(split(&[1]), None); + assert_eq!(split(&[]), None); +} + +#[test] +fn close_only_green_with_sha_on_main() { + assert_eq!( + outcome(true, Some("abc")), + Outcome::Close { + sha: "abc".to_string() + } + ); + assert!(matches!( + outcome(false, Some("abc")), + Outcome::KeepOpen { .. } + )); + assert!(matches!(outcome(true, None), Outcome::KeepOpen { .. })); + assert!(matches!(outcome(true, Some("")), Outcome::KeepOpen { .. })); +} + +#[test] +fn select_records_the_head_each_pick_was_judged_at() { + let cands = vec![cand(4, &["a.rs"]), cand(2, &["b.rs"])]; + let sel = select(&cands); + assert_eq!(sel.picked, [2, 4]); + assert_eq!(sel.heads, ["sha2", "sha4"]); +} diff --git a/docs/roadmaps/entries/PMAT-5737.yaml b/docs/roadmaps/entries/PMAT-5737.yaml new file mode 100644 index 0000000000..6ed288b1c8 --- /dev/null +++ b/docs/roadmaps/entries/PMAT-5737.yaml @@ -0,0 +1,19 @@ +- id: PMAT-5737 + github_issue: 5737 + item_type: task + title: 'T-rollup: rollup bot (aprender-rollup crate + gh layer + bin)' + status: planned + priority: high + assigned_to: null + created: 2026-10-19T07:13:22Z + updated: 2026-10-19T07:13:22Z + spec: null + acceptance_criteria: + - 'INTAKE refs #5394; step 1 on feat/rollup-bot @138d94c28f' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + notes: null + epic: 5510 diff --git a/docs/roadmaps/roadmap.yaml b/docs/roadmaps/roadmap.yaml index 73533d0fce..3bce667faa 100644 --- a/docs/roadmaps/roadmap.yaml +++ b/docs/roadmaps/roadmap.yaml @@ -25321,3 +25321,22 @@ Not done, pending the ruling: moving the 31 fixture files to an obviously synthe - kind:code notes: null epic: 3998 +- id: PMAT-5737 + github_issue: 5737 + item_type: task + title: 'T-rollup: rollup bot (aprender-rollup crate + gh layer + bin)' + status: planned + priority: high + assigned_to: null + created: 2026-10-19T07:13:22Z + updated: 2026-10-19T07:13:22Z + spec: null + acceptance_criteria: + - 'INTAKE refs #5394; step 1 on feat/rollup-bot @138d94c28f' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + notes: null + epic: 5510 diff --git a/evidence/binary/snapshot.jsonl b/evidence/binary/snapshot.jsonl index 47fe538704..124958c6dc 100644 --- a/evidence/binary/snapshot.jsonl +++ b/evidence/binary/snapshot.jsonl @@ -19,6 +19,7 @@ {"schema":"binary-snapshot/v1","package":"aprender-qa-certify","target":"aprender-qa-readme-sync","name":"aprender-qa-readme-sync","src_path":"crates/aprender-qa-certify/src/main.rs","git_sha":"47d198202ff793c91cb790beba358f531505cb51","version":"aprender-qa-readme-sync 0.70.0 (47d198202)","version_sha256":"f5272da6ac0c9ce78272891e6fa9fab2d004480c80c75a0ce56a42be65a4d507","binary_sha256":"5a1a71cc88d2eafe930c6578e27e526e06b88f77ce8547197b7677c78df68453","help_sha256":"edfc1fade8953df6a4cbfd49e5e0a1da55f604dd8aeffa0d49763b3c39bc5fa3","help_exit":0,"commands":[],"options":["--csv","--readme"],"positionals":[],"routes":[],"tools":[]} {"schema":"binary-snapshot/v1","package":"aprender-qa-cli","target":"apr-qa","name":"apr-qa","src_path":"crates/aprender-qa-cli/src/main.rs","git_sha":"47d198202ff793c91cb790beba358f531505cb51","version":"apr-qa 0.70.0 (47d198202)","version_sha256":"31ef071f9c7b471a1fb09798ad92435623491583a674cdb5de7e1e3c88c41aa2","binary_sha256":"1b9087ae6055960718263d8491d18e93712130d37a597ea2a4c4140b81b3303c","help_sha256":"a8cb537ca5744570d2eb338170b8da5d48088358f17dbd88aa8a538d3a5f102f","help_exit":0,"commands":["bootstrap","certify","export-csv","export-evidence","generate","kernel-coverage","list","lock-playbooks","parity","report","run","score","tickets","tools","validate-contract"],"options":[],"positionals":[],"routes":[],"tools":[]} {"schema":"binary-snapshot/v1","package":"aprender-rag-cli","target":"aprender-rag","name":"aprender-rag","src_path":"crates/aprender-rag-cli/src/main.rs","git_sha":"47d198202ff793c91cb790beba358f531505cb51","version":"aprender-rag 0.70.0 (47d198202)","version_sha256":"a7a00dd65876e1a486acf580879c8528d9262d20a6211a293b8558b58838967d","binary_sha256":"d6945efe30bd87ec5865d7bd48a7f232399f9b1a3e03c5f972ff04ac7d99e857","help_sha256":"49786a548b7c25c6a67c16db5bd8b1ab336b410556955ef5f7f6657a6fdbd26d","help_exit":0,"commands":["demo","extract-frames","index","info","query","transcribe"],"options":[],"positionals":[],"routes":[],"tools":[]} +{"schema":"binary-snapshot/v1","package":"aprender-ci-tools","target":"aprender-rollup","name":"aprender-rollup","src_path":"crates/aprender-ci-tools/src/bin/aprender-rollup.rs","git_sha":"acde1c41ec2a53a4783464a72f12ac097702256c","version":"","version_sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","binary_sha256":"1a8d68713c3c2cdbf8a416eef593606e234d0687eace936e7c23159787c8f312","help_sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","help_exit":2,"commands":[],"options":[],"positionals":[],"routes":[],"tools":[]} {"schema":"binary-snapshot/v1","package":"aprender-simulate","target":"aprender-simulate","name":"aprender-simulate","src_path":"crates/aprender-simulate/src/main.rs","git_sha":"47d198202ff793c91cb790beba358f531505cb51","version":"aprender-simulate 0.70.0 (47d198202)","version_sha256":"a7ad86bad2c4090750f8d805b282e4002f21b3e62ddf30924c6549b080f64532","binary_sha256":"1d57fceab026705fbcb61e62721b0cbdc4299277f2e4cfd76340981781ccb241","help_sha256":"adaaa5979da2b555e584a1ca8e918e8c4859c1bab12c89c3924044a1a002f0ee","help_exit":0,"commands":["emc-check","emc-validate","help","list-emc","render","run","validate","verify","version"],"options":[],"positionals":[],"routes":[],"tools":[]} {"schema":"binary-snapshot/v1","package":"aprender-test-cli","target":"aprender-test-cli","name":"aprender-test-cli","src_path":"crates/aprender-test-cli/src/main.rs","git_sha":"47d198202ff793c91cb790beba358f531505cb51","version":"aprender-test-cli 0.70.0 (47d198202)","version_sha256":"419743418d4db8edf98ce3859c7dbf8339e7f6e1518078ebeea25cccf9edbfba","binary_sha256":"879b610adf5b3ad8e501d4393a3baa5bf33e37a8564f588c440596260a9ed809","help_sha256":"097224de11e0b6ba9e38e363e4d4238f17aa9aca0a9425627faed1abba0b1d36","help_exit":0,"commands":["animation check","audio check","av-sync check","av-sync report","build","comply","comply check","comply diff","comply enforce","comply migrate","comply report","config","coverage","init","llm bench","llm data-audit","llm experiment","llm experiment compare","llm experiment init","llm experiment status","llm gen-dataset","llm load","llm report","llm score","llm sweep","llm test","playbook","record","report","serve","serve score","serve tree","serve viz","stress","test","video check","watch"],"options":["--color","--quiet","--verbose"],"positionals":[],"routes":[],"tools":[]} {"schema":"binary-snapshot/v1","package":"aprender-train-bench","target":"aprender-train-bench","name":"aprender-train-bench","src_path":"crates/aprender-train-bench/src/main.rs","git_sha":"47d198202ff793c91cb790beba358f531505cb51","version":"aprender-train-bench 0.70.0 (47d198202)","version_sha256":"1500e4fce255af39f1026cfdb9a0347ad4d81834fd9590fa9d552508b028cff1","binary_sha256":"056d8f340d7d22a182be7ccf869033513c527d91218df7d9d0cdf77a170e1d4e","help_sha256":"b5aa1cc255f9c515f81b46bee28321b64bbf1e66cbae739517d188b7dc0ddf6a","help_exit":0,"commands":["ablation","alpha","compare","cost-performance","recommend","temperature"],"options":["--format","--no-color","--quiet","--verbose"],"positionals":[],"routes":[],"tools":[]} diff --git a/scripts/tree_reader_tests.txt b/scripts/tree_reader_tests.txt index e97a5e952b..a560a1bca2 100644 --- a/scripts/tree_reader_tests.txt +++ b/scripts/tree_reader_tests.txt @@ -196,6 +196,7 @@ aprender-rag-cli --lib aprender-review-experiment --lib build_corpus aprender-review-experiment --lib contamination aprender-review-experiment --lib prereg +aprender-rollup --lib aprender-serve --lib capability aprender-serve --lib cli::cli_tests aprender-serve --lib gguf::inference::matmul