Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ Where you use Rally:
number, so two teams never write the same ID.
- **Claim** a ready story: `rally claim <STORY> --team <team>`. Give it up with
`rally release <STORY> --team <team>`.
- **Wait for the checks** on a pushed pull request with `rally wait-green <PR> --repo <owner/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 <STORY> --team <team> --evidence "<repo>@<full commit>"`.
To fix a typo in the evidence while it's in review, `rally amend <STORY> --team <team> --evidence "..."`.
- **Verify someone else's work**, never your own: read it at a commit, then
Expand Down
8 changes: 8 additions & 0 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <PR or sha>`, 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).
34 changes: 34 additions & 0 deletions docs/wait-green.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Waiting for a pull request's checks (RAL-185)

```sh
rally wait-green <PR> --repo <owner/repo> # a pull request: its head is pinned at the start
rally wait-green <full sha> --repo <owner/repo> # a commit
[--head <full sha>] [--base main] [--check <name>]... [--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 <team> --evidence "Castor-o7/rally@<head>"
```
3 changes: 3 additions & 0 deletions src/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ Where you use Rally:
number, so two teams never write the same ID.
- **Claim** a ready story: `rally claim <STORY> --team <team>`. Give it up with
`rally release <STORY> --team <team>`.
- **Wait for the checks** on a pushed pull request with `rally wait-green <PR> --repo <owner/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 <STORY> --team <team> --evidence \"<repo>@<full commit>\"`.
To fix a typo in the evidence while it's in review, `rally amend <STORY> --team <team> --evidence \"...\"`.
- **Verify someone else's work**, never your own: read it at a commit, then
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
150 changes: 149 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1140,6 +1140,34 @@ enum Command {
#[arg(long)]
verified: Option<String>,
},
/// 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<String>,
/// 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<String>,
/// 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<String>,
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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<u64> = 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<String> = 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<String, String>),
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<String, String> {
let token =
Expand Down Expand Up @@ -4177,7 +4322,10 @@ fn run(cli: Cli) -> Result<String, Box<dyn std::error::Error>> {
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 => {
Expand Down
Loading
Loading