diff --git a/docs/design.md b/docs/design.md index 5cd2c11..342912d 100644 --- a/docs/design.md +++ b/docs/design.md @@ -573,7 +573,9 @@ on, not whether those dependencies are satisfied, and beads already answers that. `bd swarm status` shows Ready as a first-class state alongside Completed, Active and Blocked, so a viewer that collapsed Ready into plain "open" would be throwing away a distinction beads makes. One call each per project, intersected -with the tree's ids. bd reads no edge to another project's bead, and that is +with the tree's ids. A bare `bd ready` leaves gates out as work nobody claims, +so **`bd ready --type gate --limit 0 --json`** is asked beside it, and a gate +with no open blocker is ready like any other bead. bd reads no edge to another project's bead, and that is the one place `bdi` adds to its answer: see *Across projects*. **`bd list --all --include-gates --limit 0 --json` and `bd query diff --git a/src/collect/bd.rs b/src/collect/bd.rs index 26bf0a6..ead012a 100644 --- a/src/collect/bd.rs +++ b/src/collect/bd.rs @@ -733,12 +733,20 @@ impl Tracker for Reader<'_> { /// bd computes readiness itself and treats it as a state of its own, so /// it is asked for rather than inferred from status. + /// + /// A bare `bd ready` leaves gates out as work nobody claims, and answers + /// for them by the same rule when asked by type, so gates are asked for + /// beside it. fn ready(&self) -> Result, RunFailure> { - let out = self.asked(&["ready", "--limit", "0", "--json"])?; - Ok(rows(&out, "ready")? - .into_iter() - .map(|bead| bead.id) - .collect()) + let (work, gates) = together( + || self.asked(&["ready", "--limit", "0", "--json"]), + || self.asked(&["ready", "--type", "gate", "--limit", "0", "--json"]), + ); + let mut ready = BTreeSet::new(); + for out in [work?, gates?] { + ready.extend(rows(&out, "ready")?.into_iter().map(|bead| bead.id)); + } + Ok(ready) } /// A dep-tree row carries its tree parent, not its blocker set: a bead @@ -1536,7 +1544,9 @@ mod tests { /// `bdi` was launched from holds, which the adapter captured once. #[test] fn a_project_configuring_nothing_is_read_on_the_ambient_credential() { - let runner = FakeRunner::default().with(&spelled("ready --limit 0 --json"), "[]"); + let runner = FakeRunner::default() + .with(&spelled("ready --limit 0 --json"), "[]") + .with(&spelled("ready --type gate --limit 0 --json"), "[]"); let cli = launched_with(&runner, Some("hunter2")); let tracker = cli @@ -2209,7 +2219,9 @@ mod tests { fn ready_ids_returns_the_set_bd_considers_startable() { let out = r#"[{"id":"p-1.1","title":"a","status":"open"}, {"id":"p-1.3","title":"b","status":"open"}]"#; - let runner = FakeRunner::default().with(&spelled("ready --limit 0 --json"), out); + let runner = FakeRunner::default() + .with(&spelled("ready --limit 0 --json"), out) + .with(&spelled("ready --type gate --limit 0 --json"), "[]"); let got = opened(&runner).ready().unwrap(); @@ -2221,6 +2233,25 @@ mod tests { ); } + /// A bare `bd ready` leaves gates out as work nobody claims, and lists + /// them when asked for by type, by the same rule as any other bead. + #[test] + fn a_gate_bd_lists_when_asked_for_gates_is_ready() { + let runner = FakeRunner::default() + .with( + &spelled("ready --limit 0 --json"), + r#"[{"id":"p-1.1","title":"a","status":"open"}]"#, + ) + .with( + &spelled("ready --type gate --limit 0 --json"), + r#"[{"id":"p-wg1","title":"Gate: gh:pr","status":"open","issue_type":"gate"}]"#, + ); + + let got = opened(&runner).ready().unwrap(); + + assert_eq!(got, BTreeSet::from(["p-1.1".into(), "p-wg1".into()])); + } + /// The shape a real tracker produces: a bead blocked by two beads, whose /// dep-tree row names only one of them. #[test] diff --git a/tests/an_open_gate_with_no_open_blocker_is_ready.rs b/tests/an_open_gate_with_no_open_blocker_is_ready.rs new file mode 100644 index 0000000..c605afd --- /dev/null +++ b/tests/an_open_gate_with_no_open_blocker_is_ready.rs @@ -0,0 +1,79 @@ +//! An open gate with no open blocker is as ready as any other bead, in the +//! listing `bdi --beads` writes and on its row of the tree. +//! +//! A bare `bd ready` leaves gates out, as work nobody claims, so the shim +//! answers the two questions apart the way bd does: the bare one with no +//! gate, and the one asked for gates with the gates that have no open +//! blocker. + +mod terminal; + +use std::path::Path; +use std::process::Command; + +use serde_json::Value; +use terminal::a_home_naming_one_project; +use terminal::shims::ShimmedTracker; + +/// Three beads each blocked by a `gh:pr` gate, as bd 1.3.0 wrote them. +const GH_PR_GATES: &str = include_str!("fixtures/bd_1.3.0_gh_pr_gates.json"); + +/// What bd says is ready when asked for gates: all three, since none of +/// them waits on anything. +const READY_GATES: &str = r#"[ + {"id":"dun-w8h","title":"Gate: gh:pr","status":"open","issue_type":"gate"}, + {"id":"dun-5s6","title":"Gate: gh:pr","status":"open","issue_type":"gate"}, + {"id":"dun-auk","title":"Gate: gh:pr","status":"open","issue_type":"gate"} +]"#; + +/// What a one-shot `bdi` run in `home` with `args` wrote, read as JSON. +fn one_shot(home: &Path, tracker: &ShimmedTracker, args: &[&str]) -> Value { + let out = Command::new(env!("CARGO_BIN_EXE_bdi")) + .args(args) + .current_dir(home) + .env("HOME", home) + .env_remove("BEADS_DIR") + .env_remove("BEADS_DOLT_PASSWORD") + .env_remove("BDI_PROJECT") + .envs(tracker.environment()) + .output() + .expect("bdi runs"); + assert!( + out.status.success(), + "bdi exited {}: {}", + out.status, + String::from_utf8_lossy(&out.stderr) + ); + serde_json::from_slice(&out.stdout).expect("bdi writes JSON") +} + +fn ready<'a>(beads: impl IntoIterator, id: &str) -> &'a Value { + &beads + .into_iter() + .find(|bead| bead["id"] == id) + .unwrap_or_else(|| panic!("{id} is listed"))["ready"] +} + +#[test] +fn an_open_gate_with_no_open_blocker_is_ready_in_the_listing_and_on_its_row() { + let home = a_home_naming_one_project("ready-gate"); + let tracker = ShimmedTracker::beside(&home); + tracker.holds(GH_PR_GATES); + tracker.answers_with("ready --type gate --limit 0 --json", READY_GATES); + + let listing = one_shot(&home, &tracker, &["--beads"]); + let forest = one_shot(&home, &tracker, &["--json", "--all"]); + + assert_eq!(tracker.unanswered(), Vec::::new()); + let listed = listing["beads"].as_array().expect("beads"); + let rows: Vec<&Value> = forest["trees"] + .as_array() + .expect("trees") + .iter() + .flat_map(|tree| tree["nodes"].as_array().expect("nodes")) + .collect(); + for (id, is_ready) in [("dun-w8h", true), ("dun-5s6", true), ("dun-4ga", false)] { + assert_eq!(ready(listed, id), is_ready, "{id} in the listing"); + assert_eq!(ready(rows.iter().copied(), id), is_ready, "{id} on its row"); + } +} diff --git a/tests/terminal/shims.rs b/tests/terminal/shims.rs index b5a90cf..732955c 100644 --- a/tests/terminal/shims.rs +++ b/tests/terminal/shims.rs @@ -152,6 +152,7 @@ impl ShimmedTracker { answer("query ephemeral=true --limit 0 --json", &[]); answer("query ephemeral=true --all --limit 0 --json", &[]); answer("ready --limit 0 --json", &[]); + answer("ready --type gate --limit 0 --json", &[]); answer("blocked --json", &[]); }