diff --git a/CLAUDE.md b/CLAUDE.md index c8035a2..cee83b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,6 +28,9 @@ Where you use Rally: number, so two teams never write the same ID. - **Claim** a ready story: `rally claim --team `. Give it up with `rally release --team `. +- **Wait for the checks** on a pushed pull request with `rally wait-green --repo `: it exits 0 + once every check is green at the head, 1 on a red, 3 if the head moved; run it in the background and + submit when it says green. - **Submit** it when it's pushed: `rally submit --team --evidence "@"`. To fix a typo in the evidence while it's in review, `rally amend --team --evidence "..."`. - **Verify someone else's work**, never your own: read it at a commit, then diff --git a/docs/roadmap.md b/docs/roadmap.md index f270c8e..bf4687e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1449,3 +1449,11 @@ principles named were durable, accurate and reactive. Found in rally 0x7 by plus going on after 10 s without it; reads the widths once the bar's transitions have finished (`getAnimations`), not after a fixed 1 s, which could read a width mid-transition when frames are starved; and draws its status again if the page's own 30 s poll redrew the bar in between; and it still fails with the style update removed. +- [ ] **RAL-185: `rally wait-green `, one command that waits for every check at a pinned head.** From + the watchman (2026-11-14), after earnest-raccoon's home-made jq waiters never exited all night and the merge + queue waited on green pull requests. Done when: observed, recording nothing, it waits for every check that ran + on the base's tip and its first parent (check runs and commit statuses, so ci-local's too), or those named with + `--check`; pins a pull request's head and ends when it moves; exits 0 green, 1 red (named), 3 moved, 4 timed + out (saying what was pending or missing), 2 on a usage error; retries a failed read until the timeout; the + Rally block in CLAUDE.md tells teams to use it; with unit tests and a CLI test against a stand-in `gh` + (docs/wait-green.md). diff --git a/docs/wait-green.md b/docs/wait-green.md new file mode 100644 index 0000000..29d9dc9 --- /dev/null +++ b/docs/wait-green.md @@ -0,0 +1,34 @@ +# Waiting for a pull request's checks (RAL-185) + +```sh +rally wait-green --repo # a pull request: its head is pinned at the start +rally wait-green --repo # a commit + [--head ] [--base main] [--check ]... [--timeout 1h] [--every 30s] +``` + +It waits until every check on the head has finished, then prints what each ended as and exits: + +| exit | verdict | meaning | +|------|--------------|----------------------------------------------------------------------------------| +| 0 | `green` | every expected check is there and passed (success, neutral or skipped) | +| 1 | `red` | a check finished and didn't pass; named, with its address | +| 3 | `moved` | the pull request's head is no longer the pinned commit (or `--head`): wait again | +| 4 | `timed_out` | still not finished: what was pending and what hadn't appeared yet | +| 2 | | a usage error, such as a repository that isn't `owner/repo` | + +**Which checks.** Unless `--check` names them, it waits for every check that ran on the base branch's tip +and its first parent: GitHub's check runs and the commit statuses, such as ci-local's `local/check`. So a +head whose GitHub checks have finished while ci-local's status hasn't been posted yet is still waiting, +not green. A check there that isn't expected still counts: a red is red. + +**Observed, recording nothing.** It reads GitHub through `gh` and writes nothing to the ledger. A read that +fails is tried again until the timeout, and the last failure is said as `last_read_failed`. + +**Why it exists.** On 2026-11-14 a team waited on its pull requests with a home-made `jq` loop that never +exited: `[...]|length,(.statuses|length)` applies `.statuses` to the array, so its test could never +pass. For a whole night green pull requests sat unsubmitted while the merge queue waited on them. Use this +instead, in the background, and submit when it says green: + +```sh +rally wait-green 270 --repo Castor-o7/rally && rally submit RAL-183 --team --evidence "Castor-o7/rally@" +``` diff --git a/src/init.rs b/src/init.rs index c2419b1..8838b41 100644 --- a/src/init.rs +++ b/src/init.rs @@ -57,6 +57,9 @@ Where you use Rally: number, so two teams never write the same ID. - **Claim** a ready story: `rally claim --team `. Give it up with `rally release --team `. +- **Wait for the checks** on a pushed pull request with `rally wait-green --repo `: it exits 0 + once every check is green at the head, 1 on a red, 3 if the head moved; run it in the background and + submit when it says green. - **Submit** it when it's pushed: `rally submit --team --evidence \"@\"`. To fix a typo in the evidence while it's in review, `rally amend --team --evidence \"...\"`. - **Verify someone else's work**, never your own: read it at a commit, then diff --git a/src/lib.rs b/src/lib.rs index 397e9b5..5b106da 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -58,6 +58,7 @@ pub mod stall; pub mod store; pub mod view; pub mod views; +pub mod wait_green; pub mod wake; pub mod watchman_brief; pub mod web; diff --git a/src/main.rs b/src/main.rs index a48cd66..784af94 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1140,6 +1140,34 @@ enum Command { #[arg(long)] verified: Option, }, + /// Wait until every check on a pull request's head, or on a commit, has finished, and say + /// whether each passed (RAL-185). Observed, recording nothing. It waits for every check that ran + /// on the base branch's tip (GitHub's check runs and commit statuses, such as ci-local's), unless + /// --check names them. A pull request's head is pinned when the wait starts. Exits 0 when every + /// check passed, 1 on a check that didn't, 3 when the pull request's head moved, 4 at the + /// timeout. See docs/wait-green.md. + WaitGreen { + /// A pull request's number, or a full commit. + target: String, + /// The repository, as owner/repo. + #[arg(long)] + repo: String, + /// For a pull request: the head it must be at, a full commit; it ends with 3 at once if not. + #[arg(long)] + head: Option, + /// For a commit: the branch whose tip says which checks to wait for. + #[arg(long, default_value = "main")] + base: String, + /// A check to wait for, by name (repeat for each), instead of those on the base's tip. + #[arg(long = "check")] + checks: Vec, + /// How long to wait in all. + #[arg(long, default_value = "1h", value_parser = parse_window)] + timeout: u64, + /// How often to read the checks again. + #[arg(long, default_value = "30s", value_parser = parse_window)] + every: u64, + }, /// Show the state of every story, or one story with its history. Show { story: Option, @@ -1280,6 +1308,36 @@ fn main() -> ExitCode { } }; } + if let Command::WaitGreen { + target, + repo, + head, + base, + checks, + timeout, + every, + } = &cli.command + { + return match wait_green( + target, + repo, + head.as_deref(), + base, + checks, + *timeout, + *every, + ) { + Ok((code, out)) => { + let text = serde_json::to_string_pretty(&out).unwrap_or_default(); + println!("{}", escape_for_terminal(&text)); + ExitCode::from(code) + } + Err(error) => { + eprintln!("rally: {}", escape_for_terminal(&error)); + ExitCode::from(2) + } + }; + } if let Command::Levi { repo, levi } = &cli.command { return match rally::levi::read(levi, repo) { Ok(out) => { @@ -1307,6 +1365,93 @@ fn main() -> ExitCode { } } +/// `rally wait-green` (RAL-185): pins the head, reads which checks to expect from the base's tip +/// unless they're named, and waits. Gives the exit code and what to print; `Err` is a usage error. +fn wait_green( + target: &str, + repo: &str, + head: Option<&str>, + base: &str, + named: &[String], + timeout_ms: u64, + every_ms: u64, +) -> Result<(u8, Value), String> { + if !rally::evidence::valid_repo(repo) { + return Err(format!("{repo:?} isn't owner/repo")); + } + let full = |sha: &str| sha.len() == 40 && sha.bytes().all(|b| b.is_ascii_hexdigit()); + if head.is_some_and(|sha| !full(sha)) { + return Err("--head takes a full commit".into()); + } + let pr: Option = target.parse().ok().filter(|_| !full(target)); + let (pinned, base) = match pr { + Some(number) => { + let (now, base) = web::pr_head_and_base(repo, number)?; + if let Some(wanted) = head.filter(|wanted| *wanted != now) { + let out = json!({ + "kind": "observed", "recorded": false, "verdict": "moved", "pr": number, + "head": wanted, "moved_to": now, + "said": format!("pull request {number} is at {now}, not {wanted}"), + }); + return Ok((3, out)); + } + (now, base) + } + None if full(target) => (target.to_owned(), base.to_owned()), + None => { + return Err(format!( + "{target:?} is neither a pull request's number nor a full commit" + )); + } + }; + let (expected, expected_from) = if named.is_empty() { + // The tip and its first parent: just after a merge, the tip's own local statuses may not + // have been posted yet, and the parent's long have. + let tip = web::branch_tip(repo, &base)?; + let parent = web::first_parent(repo, &tip)?; + let mut names: Vec = Vec::new(); + for commit in std::iter::once(&tip).chain(parent.as_ref()) { + for check in web::commit_checks(repo, commit)? { + if !names.contains(&check.name) { + names.push(check.name); + } + } + } + names.sort(); + ( + names, + format!("the checks on {base}'s tip, {tip}, and its first parent"), + ) + } else { + (named.to_vec(), "--check".to_owned()) + }; + let head_now = |number: u64| move || web::pr_head_and_base(repo, number).map(|(head, _)| head); + let pr_head = pr.map(head_now); + let checks = || web::commit_checks(repo, &pinned); + let sleep = std::thread::sleep; + let reads = rally::wait_green::Reads { + head_now: pr_head + .as_ref() + .map(|f| f as &dyn Fn() -> Result), + checks: &checks, + sleep: &sleep, + }; + let (ended, mut out) = rally::wait_green::wait( + &pinned, + &expected, + &reads, + Duration::from_millis(timeout_ms), + Duration::from_millis(every_ms), + ); + out["repo"] = json!(repo); + if let Some(number) = pr { + out["pr"] = json!(number); + } + out["expected"] = json!(expected); + out["expected_from"] = json!(expected_from); + Ok((ended.code(), out)) +} + /// The one GitHub token in the file at `path`. fn read_github_token(path: &Path) -> Result { let token = @@ -4177,7 +4322,10 @@ fn run(cli: Cli) -> Result> { return Ok(json!({"revoked": name}).to_string()); } Command::Init { .. } => unreachable!("handled before the ledger is opened"), - Command::SessionStart { .. } | Command::Levi { .. } | Command::CheckGithubToken { .. } => { + Command::SessionStart { .. } + | Command::Levi { .. } + | Command::CheckGithubToken { .. } + | Command::WaitGreen { .. } => { unreachable!("handled in main") } Command::Pair => { diff --git a/src/wait_green.rs b/src/wait_green.rs new file mode 100644 index 0000000..04aa4d9 --- /dev/null +++ b/src/wait_green.rs @@ -0,0 +1,507 @@ +//! `rally wait-green ` (RAL-185): wait until every check on a pinned head has finished, +//! and say whether each passed. On 2026-11-14 a team's home-made waiter, a jq loop, never exited, +//! so for a whole night green pull requests sat unsubmitted while the merge queue waited on them. +//! This is the one waiter every team can use instead. +//! +//! Observed, recording nothing. Which checks to wait for is read, not assumed: every check that ran +//! on the base branch's tip (GitHub's check runs and commit statuses, such as ci-local's +//! `local/check`), unless `--check` names them. So a head whose GitHub checks have finished but +//! whose local statuses haven't been posted yet is still waiting, not green. A pull request's head +//! is pinned when the wait starts: if the pull request moves to another commit, the wait ends and +//! says so, since a green on the old head says nothing about the new one. + +use std::collections::BTreeMap; +use std::time::Duration; + +use serde_json::{Value, json}; + +/// One check on a commit, the newest of its name: a check run or a commit status. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Check { + pub name: String, + /// `pending`, `success`, or what else it ended as (`failure`, `cancelled`, `timed_out`, ...). + pub state: String, + pub url: Option, +} + +/// The checks on a commit, from `GET /repos/{repo}/commits/{sha}/check-runs` and `.../status`. +/// A check run re-run under the same name is given once, its newest run. +pub fn checks(check_runs: &Value, status: &Value) -> Vec { + let mut by_name: BTreeMap = BTreeMap::new(); + for run in check_runs["check_runs"].as_array().into_iter().flatten() { + let Some(name) = run["name"].as_str() else { + continue; + }; + let id = run["id"].as_u64().unwrap_or(0); + let state = if run["status"] == "completed" { + run["conclusion"].as_str().unwrap_or("unknown").to_owned() + } else { + "pending".to_owned() + }; + let check = Check { + name: name.to_owned(), + state, + url: run["html_url"].as_str().map(str::to_owned), + }; + match by_name.get(name) { + Some((newer, _)) if *newer > id => {} + _ => { + by_name.insert(name.to_owned(), (id, check)); + } + } + } + // GitHub's combined status already gives the newest status of each context. + for status in status["statuses"].as_array().into_iter().flatten() { + let Some(name) = status["context"].as_str() else { + continue; + }; + let state = match status["state"].as_str() { + Some("success") => "success", + Some("pending") => "pending", + Some("error") => "error", + _ => "failure", + }; + by_name.insert( + name.to_owned(), + ( + u64::MAX, + Check { + name: name.to_owned(), + state: state.to_owned(), + url: status["target_url"].as_str().map(str::to_owned), + }, + ), + ); + } + by_name.into_values().map(|(_, check)| check).collect() +} + +/// Whether a finished check passed: a success, or a check GitHub skipped or found neutral. +fn passed(state: &str) -> bool { + matches!(state, "success" | "neutral" | "skipped") +} + +/// Where the checks on a head stand against the ones expected. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Standing { + /// Every expected check is there and finished, and every check there passed. + Green, + /// A check finished and didn't pass: the first such is enough to end the wait. + Red(Vec), + /// Still running, or an expected check hasn't appeared yet: what's pending and what's missing. + Waiting { + pending: Vec, + missing: Vec, + }, +} + +/// Judges `checks` against `expected` (names). With nothing expected, at least one check must be +/// there, so a head nothing has started on yet isn't called green. +pub fn judge(checks: &[Check], expected: &[String]) -> Standing { + let red: Vec = checks + .iter() + .filter(|check| check.state != "pending" && !passed(&check.state)) + .cloned() + .collect(); + if !red.is_empty() { + return Standing::Red(red); + } + let pending: Vec = checks + .iter() + .filter(|check| check.state == "pending") + .map(|check| check.name.clone()) + .collect(); + let mut missing: Vec = expected + .iter() + .filter(|name| !checks.iter().any(|check| &check.name == *name)) + .cloned() + .collect(); + if checks.is_empty() && missing.is_empty() { + missing.push("any check".to_owned()); + } + if pending.is_empty() && missing.is_empty() { + Standing::Green + } else { + Standing::Waiting { pending, missing } + } +} + +/// How a wait ended. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Ended { + Green, + Red, + /// The pull request's head moved off the pinned commit: to this one. + Moved(String), + TimedOut, +} + +impl Ended { + /// The process's exit code: 0 green, 1 red, 3 moved, 4 timed out (2 is a usage error). + pub fn code(&self) -> u8 { + match self { + Self::Green => 0, + Self::Red => 1, + Self::Moved(_) => 3, + Self::TimedOut => 4, + } + } + + fn word(&self) -> &'static str { + match self { + Self::Green => "green", + Self::Red => "red", + Self::Moved(_) => "moved", + Self::TimedOut => "timed_out", + } + } +} + +/// What the wait reads, given so a test can stand in for GitHub. +pub struct Reads<'a> { + /// The pull request's head now, for a wait on a pull request; `None` for a commit. + pub head_now: Option<&'a dyn Fn() -> Result>, + /// The checks on the pinned head. + pub checks: &'a dyn Fn() -> Result, String>, + pub sleep: &'a dyn Fn(Duration), +} + +/// Waits on `head` until its checks are green or red, the pull request moves off it, or `timeout` +/// has passed, reading every `every`. A read that fails is tried again until the timeout, and the +/// last such failure is said. Gives how it ended and the JSON to print. +pub fn wait( + head: &str, + expected: &[String], + reads: &Reads, + timeout: Duration, + every: Duration, +) -> (Ended, Value) { + let mut waited = Duration::ZERO; + let mut last_error: Option = None; + // A pull request's head that couldn't be read: the checks on the pinned head still count, but + // a move can't be seen meanwhile, so it's said. + let mut head_error: Option = None; + let mut last: Vec = Vec::new(); + let mut last_standing = Standing::Waiting { + pending: Vec::new(), + missing: Vec::new(), + }; + let ended = loop { + if let Some(head_now) = reads.head_now { + match head_now() { + Ok(now) if now != head => break Ended::Moved(now), + Ok(_) => head_error = None, + Err(error) => head_error = Some(error), + } + } + match (reads.checks)() { + Ok(checks) => { + last_error = None; + last_standing = judge(&checks, expected); + last = checks; + match last_standing { + Standing::Green => break Ended::Green, + Standing::Red(_) => break Ended::Red, + Standing::Waiting { .. } => {} + } + } + Err(error) => last_error = Some(error), + } + if waited >= timeout { + break Ended::TimedOut; + } + let step = every.min(timeout - waited).max(Duration::from_millis(1)); + (reads.sleep)(step); + waited += step; + }; + let mut out = json!({ + "kind": "observed", + "recorded": false, + "verdict": ended.word(), + "head": head, + "waited_s": waited.as_secs(), + "checks": last.iter().map(|check| json!({ + "name": check.name, "state": check.state, "url": check.url, + })).collect::>(), + }); + match &ended { + Ended::Green => { + out["said"] = json!(format!( + "every check passed at {head}: {} of them", + last.len() + )); + } + Ended::Red => { + let red: Vec = match &last_standing { + Standing::Red(red) => red + .iter() + .map(|check| format!("{} {}", check.name, check.state)) + .collect(), + _ => Vec::new(), + }; + out["said"] = json!(format!("not green at {head}: {}", red.join(", "))); + } + Ended::Moved(now) => { + out["moved_to"] = json!(now); + out["said"] = json!(format!( + "the pull request's head moved from {head} to {now}: a green on the old head says \ + nothing about the new one, so wait again on it" + )); + } + Ended::TimedOut => { + let (pending, missing) = match &last_standing { + Standing::Waiting { pending, missing } => (pending.clone(), missing.clone()), + _ => (Vec::new(), Vec::new()), + }; + out["pending"] = json!(pending); + out["missing"] = json!(missing); + out["said"] = json!(format!( + "still not finished at {head} after {} s: pending {}; not yet there {}", + waited.as_secs(), + list(&pending), + list(&missing) + )); + } + } + if let Some(error) = last_error { + out["last_read_failed"] = json!(error); + } + if let Some(error) = head_error { + out["head_read_failed"] = json!(error); + } + (ended, out) +} + +fn list(names: &[String]) -> String { + if names.is_empty() { + "none".to_owned() + } else { + names.join(", ") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::{Cell, RefCell}; + + fn run(name: &str, id: u64, status: &str, conclusion: Option<&str>) -> Value { + json!({"name": name, "id": id, "status": status, "conclusion": conclusion, + "html_url": format!("https://github.com/x/y/runs/{id}")}) + } + + #[test] + fn checks_take_the_newest_run_of_a_name_and_every_status() { + let runs = json!({"check_runs": [ + run("check", 1, "completed", Some("failure")), + run("check", 2, "completed", Some("success")), + run("dependency-audit", 3, "in_progress", None), + ]}); + let status = json!({"statuses": [ + {"context": "local/check", "state": "pending", "target_url": null}, + {"context": "local/ci-config", "state": "error"}, + ]}); + let got = checks(&runs, &status); + let by = |name: &str| got.iter().find(|c| c.name == name).unwrap().state.clone(); + assert_eq!(got.len(), 4); + assert_eq!(by("check"), "success", "the re-run is the one that counts"); + assert_eq!(by("dependency-audit"), "pending"); + assert_eq!(by("local/check"), "pending"); + assert_eq!(by("local/ci-config"), "error"); + } + + #[test] + fn green_needs_every_expected_check_there_and_passed() { + let c = |name: &str, state: &str| Check { + name: name.into(), + state: state.into(), + url: None, + }; + let expected = vec!["check".to_owned(), "local/check".to_owned()]; + // GitHub's checks finished, the local status not yet posted: waiting, not green. + assert_eq!( + judge(&[c("check", "success")], &expected), + Standing::Waiting { + pending: vec![], + missing: vec!["local/check".into()] + } + ); + assert_eq!( + judge( + &[c("check", "success"), c("local/check", "pending")], + &expected + ), + Standing::Waiting { + pending: vec!["local/check".into()], + missing: vec![] + } + ); + assert_eq!( + judge( + &[c("check", "success"), c("local/check", "success")], + &expected + ), + Standing::Green + ); + // A red ends it at once, even with another still running. + assert!(matches!( + judge(&[c("check", "failure"), c("local/check", "pending")], &expected), + Standing::Red(red) if red.len() == 1 && red[0].name == "check" + )); + // Skipped and neutral pass; nothing at all isn't green. + assert_eq!(judge(&[c("lint", "skipped")], &[]), Standing::Green); + assert!( + matches!(judge(&[], &[]), Standing::Waiting { missing, .. } if missing == ["any check"]) + ); + } + + // Three stand-in reads, each a closure over the test's own state. + #[allow(clippy::type_complexity)] + fn reads_from<'a>( + steps: &'a RefCell>>, + heads: &'a RefCell>, + slept: &'a Cell, + ) -> ( + impl Fn() -> Result, String> + 'a, + impl Fn() -> Result + 'a, + impl Fn(Duration) + 'a, + ) { + let checks = move || { + let mut steps = steps.borrow_mut(); + Ok(if steps.len() > 1 { + steps.remove(0) + } else { + steps[0].clone() + }) + }; + let head = move || { + let mut heads = heads.borrow_mut(); + Ok(if heads.len() > 1 { + heads.remove(0) + } else { + heads[0].clone() + }) + }; + let sleep = move |_: Duration| slept.set(slept.get() + 1); + (checks, head, sleep) + } + + #[test] + fn a_wait_ends_green_red_moved_or_timed_out() { + let c = |state: &str| { + vec![Check { + name: "check".into(), + state: state.into(), + url: None, + }] + }; + let expected = vec!["check".to_owned()]; + let every = Duration::from_secs(30); + let timeout = Duration::from_secs(600); + let h = "a".repeat(40); + + let steps = RefCell::new(vec![c("pending"), c("pending"), c("success")]); + let heads = RefCell::new(vec![h.clone()]); + let slept = Cell::new(0); + let (checks, head, sleep) = reads_from(&steps, &heads, &slept); + let reads = Reads { + head_now: Some(&head), + checks: &checks, + sleep: &sleep, + }; + let (ended, out) = wait(&h, &expected, &reads, timeout, every); + assert_eq!((ended.code(), slept.get()), (0, 2), "{out}"); + assert_eq!(out["verdict"], "green"); + + let steps = RefCell::new(vec![c("pending"), c("failure")]); + let slept = Cell::new(0); + let (checks, head, sleep) = reads_from(&steps, &heads, &slept); + let reads = Reads { + head_now: Some(&head), + checks: &checks, + sleep: &sleep, + }; + let (ended, out) = wait(&h, &expected, &reads, timeout, every); + assert_eq!(ended.code(), 1, "{out}"); + assert_eq!(out["said"], format!("not green at {h}: check failure")); + + // The pull request is pushed to while it waits: a green afterwards would be another head's. + let steps = RefCell::new(vec![c("pending")]); + let heads = RefCell::new(vec![h.clone(), "b".repeat(40)]); + let slept = Cell::new(0); + let (checks, head, sleep) = reads_from(&steps, &heads, &slept); + let reads = Reads { + head_now: Some(&head), + checks: &checks, + sleep: &sleep, + }; + let (ended, out) = wait(&h, &expected, &reads, timeout, every); + assert_eq!(ended, Ended::Moved("b".repeat(40))); + assert_eq!(ended.code(), 3); + assert_eq!(out["moved_to"], "b".repeat(40)); + + // Never finishes: it stops at the timeout, never past it, and says what was outstanding. + let steps = RefCell::new(vec![c("pending")]); + let heads = RefCell::new(vec![h.clone()]); + let slept = Cell::new(0); + let (checks, head, sleep) = reads_from(&steps, &heads, &slept); + let reads = Reads { + head_now: Some(&head), + checks: &checks, + sleep: &sleep, + }; + let (ended, out) = wait(&h, &expected, &reads, Duration::from_secs(90), every); + assert_eq!((ended.code(), slept.get()), (4, 3), "{out}"); + assert_eq!(out["waited_s"], 90); + assert_eq!(out["pending"], json!(["check"])); + } + + #[test] + fn a_failed_read_is_tried_again_and_said_if_it_never_recovers() { + let h = "a".repeat(40); + let tries = Cell::new(0); + let checks = || { + tries.set(tries.get() + 1); + if tries.get() < 3 { + Err("gh: offline".to_owned()) + } else { + Ok(vec![Check { + name: "check".into(), + state: "success".into(), + url: None, + }]) + } + }; + let sleep = |_: Duration| {}; + let reads = Reads { + head_now: None, + checks: &checks, + sleep: &sleep, + }; + let (ended, out) = wait( + &h, + &[], + &reads, + Duration::from_secs(600), + Duration::from_secs(30), + ); + assert_eq!(ended, Ended::Green, "{out}"); + assert!(out.get("last_read_failed").is_none()); + + let never = || Err::, _>("gh: offline".to_owned()); + let reads = Reads { + head_now: None, + checks: &never, + sleep: &sleep, + }; + let (ended, out) = wait( + &h, + &[], + &reads, + Duration::from_secs(60), + Duration::from_secs(30), + ); + assert_eq!(ended, Ended::TimedOut); + assert_eq!(out["last_read_failed"], "gh: offline"); + } +} diff --git a/src/web.rs b/src/web.rs index 7094151..b522081 100644 --- a/src/web.rs +++ b/src/web.rs @@ -1219,6 +1219,64 @@ pub fn ci_state(repo: &str, commit: &str) -> Result { Ok(crate::digest::ci_from_runs(&runs)) } +/// The checks on `commit` in `repo` (RAL-185): its check runs and its commit statuses, read-only +/// through `gh api`. `repo` must already be checked as `owner/repo`; `commit` must be hex. +pub fn commit_checks(repo: &str, commit: &str) -> Result, String> { + if commit.is_empty() || !commit.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err("the commit isn't a hash".into()); + } + let read = |path: String| -> Result { + let out = run_gh(&["api", &path])?; + serde_json::from_str(out.trim()) + .map_err(|_| format!("gh returned something unreadable for {path}")) + }; + let runs = read(format!( + "repos/{repo}/commits/{commit}/check-runs?per_page=100" + ))?; + let status = read(format!("repos/{repo}/commits/{commit}/status?per_page=100"))?; + Ok(crate::wait_green::checks(&runs, &status)) +} + +/// `commit`'s first parent in `repo` (RAL-185), read-only through `gh api`; `None` for a root. +pub fn first_parent(repo: &str, commit: &str) -> Result, String> { + let out = run_gh(&[ + "api", + &format!("repos/{repo}/commits/{commit}"), + "--jq", + ".parents[0].sha // empty", + ])?; + let sha = out.trim(); + if sha.is_empty() { + return Ok(None); + } + if sha.len() == 40 && sha.bytes().all(|b| b.is_ascii_hexdigit()) { + Ok(Some(sha.to_owned())) + } else { + Err("gh returned a parent that isn't a commit".into()) + } +} + +/// A pull request's head commit and base branch (RAL-185), read-only through `gh pr view`. +pub fn pr_head_and_base(repo: &str, pr: u64) -> Result<(String, String), String> { + let out = run_gh(&[ + "pr", + "view", + &pr.to_string(), + "--repo", + repo, + "--json", + "headRefOid,baseRefName", + ])?; + let value: Value = serde_json::from_str(out.trim()) + .map_err(|_| "gh returned a pull request that can't be read")?; + let head = value["headRefOid"].as_str().unwrap_or_default(); + let base = value["baseRefName"].as_str().unwrap_or_default(); + if head.len() != 40 || !head.bytes().all(|b| b.is_ascii_hexdigit()) || base.is_empty() { + return Err(format!("gh gave no head or base for pull request {pr}")); + } + Ok((head.to_owned(), base.to_owned())) +} + /// The open pull requests in `repo` (RAL-105), read-only through `gh pr list`. `repo` must already /// be checked as `owner/repo`. pub fn open_prs(repo: &str) -> Result, String> { diff --git a/tests/cli.rs b/tests/cli.rs index 76c8da7..1bf3376 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1879,3 +1879,138 @@ fn the_digest_names_a_verified_story_whose_pull_request_is_behind_its_base() { "{report}" ); } + +/// RAL-185: a stand-in `gh` for `rally wait-green`, serving from `dir`: `pr.json` for `gh pr view`, +/// the base's tip as `TIP`, and for each commit `runs-.json` and `status-.json`, or, on +/// its n-th read of that kind, `runs--.json` where there is one, so checks can progress. +fn wait_green_gh(dir: &std::path::Path) -> std::path::PathBuf { + use std::os::unix::fs::PermissionsExt; + let bin = dir.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let script = format!( + r#"#!/bin/sh +d={dir} +case "$1 $2" in + "pr view") cat "$d/pr.json" ;; + api\ *) + p=$2 + case "$p" in + */check-runs*) kind=runs ;; + */status*) kind=status ;; + */commits/{tip}) echo "{parent}"; exit 0 ;; + *) echo "{tip}"; exit 0 ;; + esac + sha=${{p#*/commits/}}; sha=${{sha%%/*}} + n=$(cat "$d/n-$kind-$sha" 2>/dev/null || echo 0); echo $((n + 1)) > "$d/n-$kind-$sha" + if [ -f "$d/$kind-$sha-$n.json" ]; then cat "$d/$kind-$sha-$n.json"; else cat "$d/$kind-$sha.json"; fi ;; + *) echo "unexpected: $*" >&2; exit 1 ;; +esac +"#, + dir = dir.display(), + tip = "e".repeat(40), + parent = "f".repeat(40) + ); + std::fs::write(bin.join("gh"), script).unwrap(); + std::fs::set_permissions(bin.join("gh"), std::fs::Permissions::from_mode(0o755)).unwrap(); + bin +} + +fn wait_green(dir: &std::path::Path, args: &[&str]) -> (i32, Value) { + let bin = wait_green_gh(dir); + let path = std::env::var("PATH").unwrap_or_default(); + let out = Command::new(env!("CARGO_BIN_EXE_rally")) + .env("PATH", format!("{}:{path}", bin.display())) + .args(["wait-green", "--repo", "o/r", "--every", "1s"]) + .args(args) + .output() + .unwrap(); + let printed: Value = serde_json::from_slice(&out.stdout).unwrap_or(Value::Null); + (out.status.code().unwrap_or(-1), printed) +} + +/// RAL-185: `rally wait-green` waits for every check the base's tip had, the local statuses too, +/// and ends 0 green, 1 red, 3 when the pull request moved, 4 at its timeout; 2 is a usage error. +#[test] +fn wait_green_waits_for_every_check_the_base_had_and_says_how_it_ended() { + let dir = tempfile::tempdir().unwrap(); + let d = dir.path(); + let (head, tip) = ("a".repeat(40), "e".repeat(40)); + let write = |name: &str, body: &str| std::fs::write(d.join(name), body).unwrap(); + let runs = |state: &str| { + let (status, conclusion) = match state { + "pending" => ("in_progress", "null".to_owned()), + done => ("completed", format!("\"{done}\"")), + }; + format!( + r#"{{"check_runs":[{{"name":"check","id":1,"status":"{status}","conclusion":{conclusion}}}]}}"# + ) + }; + let status = |state: Option<&str>| match state { + Some(state) => format!(r#"{{"statuses":[{{"context":"local/check","state":"{state}"}}]}}"#), + None => r#"{"statuses":[]}"#.to_owned(), + }; + write( + "pr.json", + &format!(r#"{{"headRefOid":"{head}","baseRefName":"main"}}"#), + ); + write(&format!("runs-{tip}.json"), &runs("success")); + // Just merged: the tip's local status isn't posted yet; its first parent's is. + write(&format!("status-{tip}.json"), &status(None)); + write(&format!("runs-{}.json", "f".repeat(40)), &runs("success")); + write( + &format!("status-{}.json", "f".repeat(40)), + &status(Some("success")), + ); + // GitHub's check finishes first; ci-local's status hasn't been posted on the first read. + write(&format!("runs-{head}-0.json"), &runs("pending")); + write(&format!("runs-{head}.json"), &runs("success")); + write(&format!("status-{head}-0.json"), &status(None)); + write(&format!("status-{head}-1.json"), &status(None)); + write(&format!("status-{head}.json"), &status(Some("success"))); + let (code, out) = wait_green(d, &["7"]); + assert_eq!(code, 0, "{out}"); + assert_eq!(out["verdict"], "green"); + assert_eq!(out["head"], head); + assert_eq!(out["pr"], 7); + assert_eq!(out["expected"], serde_json::json!(["check", "local/check"])); + assert_eq!(out["recorded"], false); + assert!( + out["waited_s"].as_u64().unwrap() >= 2, + "it waited for local/check: {out}" + ); + + // Pinned to a head the pull request is no longer at. + let (code, out) = wait_green(d, &["7", "--head", &"b".repeat(40)]); + assert_eq!(code, 3, "{out}"); + assert_eq!(out["moved_to"], head); + + // A commit whose local check failed: red, named. + let red = "c".repeat(40); + write(&format!("runs-{red}.json"), &runs("success")); + write(&format!("status-{red}.json"), &status(Some("failure"))); + let (code, out) = wait_green(d, &[&red]); + assert_eq!(code, 1, "{out}"); + assert_eq!( + out["said"], + format!("not green at {red}: local/check failure") + ); + + // Never finishes: it stops at its timeout and says what was outstanding. + let slow = "d".repeat(40); + write(&format!("runs-{slow}.json"), &runs("pending")); + write(&format!("status-{slow}.json"), &status(None)); + let (code, out) = wait_green(d, &[&slow, "--timeout", "2s"]); + assert_eq!(code, 4, "{out}"); + assert_eq!(out["pending"], serde_json::json!(["check"])); + assert_eq!(out["missing"], serde_json::json!(["local/check"])); + + // Named checks instead of the base's; and a usage error is 2, apart from every verdict. + let (code, out) = wait_green(d, &[&red, "--check", "check"]); + assert_eq!( + (code, &out["verdict"]), + (1, &serde_json::json!("red")), + "a red is red even if not named: {out}" + ); + let (code, _) = wait_green(d, &["not-a-pr"]); + assert_eq!(code, 2); +}