From fa60cb844da14fee4412ec07e300e4c1b17f0433 Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Sun, 22 Feb 2026 15:26:56 -0800 Subject: [PATCH] feat(nbd): add nbd next subcommand [fc6df4] Adds `nbd next` which selects the single highest-priority ready ticket. Supports `--filter` narrowing, `--json` output as `{"next": ...}` or `{"next": null}` when nothing is ready. Makes `ticket_to_json_value` pub(crate) for reuse. Adds 7 integration tests. Co-Authored-By: Claude Sonnet 4.6 --- nbd/CLAUDE.md | 8 +- nbd/README.md | 18 ++++- nbd/src/display.rs | 2 +- nbd/src/main.rs | 64 +++++++++++++++ nbd/tests/integration.rs | 169 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 257 insertions(+), 4 deletions(-) diff --git a/nbd/CLAUDE.md b/nbd/CLAUDE.md index 01f017b..8ae8158 100644 --- a/nbd/CLAUDE.md +++ b/nbd/CLAUDE.md @@ -117,7 +117,13 @@ cargo run -- update --status in_progress --json cargo run -- update --status done --json ``` -**To see what's unblocked and ready to start:** +**To get the single best ticket to work on next:** + +```sh +cargo run -- next --json +``` + +**To see all tickets that are unblocked and ready to start:** ```sh cargo run -- ready --json diff --git a/nbd/README.md b/nbd/README.md index b5f8b25..f69034b 100644 --- a/nbd/README.md +++ b/nbd/README.md @@ -71,9 +71,23 @@ nbd update a3f9c2 --status in_progress nbd update a3f9c2 --priority 9 --type bug ``` -### Find actionable tickets +### Find the next ticket to work on -List tickets that are ready to work on — not done and with all dependencies +Returns the single highest-priority ticket that is ready to work on — not done +and with all dependencies completed: + +```sh +nbd next +nbd next --json +nbd next --filter type=bug # highest-priority ready bug +nbd next --filter priority=9 # highest-priority ready ticket at priority 9 +``` + +Exits 0 even when no ready ticket exists (`{"next": null}` in JSON mode). + +### Find all actionable tickets + +List all tickets that are ready to work on — not done and with all dependencies completed: ```sh diff --git a/nbd/src/display.rs b/nbd/src/display.rs index 0003347..d5562d9 100644 --- a/nbd/src/display.rs +++ b/nbd/src/display.rs @@ -98,7 +98,7 @@ pub fn print_ticket(ticket: &Ticket) { /// CLI `--json` output should include `id` so that consumers have all the /// information in one object. This helper re-inserts it into the serialised /// value. -fn ticket_to_json_value(ticket: &Ticket) -> serde_json::Value { +pub(crate) fn ticket_to_json_value(ticket: &Ticket) -> serde_json::Value { let mut value = serde_json::to_value(ticket).expect("ticket serialisation must not fail"); // Re-insert id at the front of the object for ergonomic CLI output. if let serde_json::Value::Object(ref mut map) = value { diff --git a/nbd/src/main.rs b/nbd/src/main.rs index a16e041..12157cf 100644 --- a/nbd/src/main.rs +++ b/nbd/src/main.rs @@ -111,6 +111,20 @@ enum Commands { filter: Vec, }, + /// Choose the highest-priority ticket that is ready to work on. + /// + /// A ticket is ready when its status is not `done` and every ticket it + /// depends on has status `done`. Returns the single highest-priority + /// ready ticket, optionally narrowed by `--filter KEY=VALUE`. + /// + /// Exits 0 even when no ready ticket exists. + Next { + /// Filter ready tickets: key=value pairs (repeatable). + /// AND between different keys, OR within same key. + #[arg(long = "filter", value_name = "KEY=VALUE")] + filter: Vec, + }, + /// Re-serialise all ticket files through the current schema. /// /// Brings existing files into conformance with the current data model: @@ -188,6 +202,8 @@ async fn dispatch(cli: Cli) -> store::Result<()> { Commands::Init => cmd_init(cli.json).await, + Commands::Next { filter } => cmd_next(filter, cli.json).await, + Commands::Ready { filter } => cmd_ready(filter, cli.json).await, Commands::Migrate { dry_run, filter } => cmd_migrate(filter, dry_run, cli.json).await, @@ -358,6 +374,54 @@ async fn cmd_ready(filter_args: Vec, json: bool) -> store::Result<()> { Ok(()) } +/// Select the single highest-priority ticket that is ready to work on. +/// +/// Uses the same readiness definition as [`cmd_ready`]: status not `done` and +/// every dependency has status `done`. Missing dependency IDs make a ticket +/// **not** ready. +/// +/// With `--json`, outputs `{"next": {...ticket...}}` when a ticket is found or +/// `{"next": null}` when none are ready, so callers always receive an object +/// with a `"next"` key. +async fn cmd_next(filter_args: Vec, json: bool) -> store::Result<()> { + let filter = crate::filter::parse_filters(&filter_args)?; + let root = find_nbd_root()?; + let all = list_tickets(&root).await?; // sorted by priority desc + + let done_ids: std::collections::HashSet<&str> = all + .iter() + .filter(|t| t.status == crate::ticket::Status::Done) + .map(|t| t.id.as_str()) + .collect(); + + let next = all.iter().find(|t| { + t.status != crate::ticket::Status::Done + && t.dependencies + .iter() + .all(|dep| done_ids.contains(dep.as_str())) + && filter.matches(t) + }); + + if json { + match next { + Some(ticket) => { + let value = serde_json::json!({ + "next": display::ticket_to_json_value(ticket) + }); + println!("{}", serde_json::to_string_pretty(&value)?); + } + None => println!("{}", serde_json::json!({"next": null})), + } + } else { + match next { + Some(ticket) => display::print_ticket(ticket), + None => println!("No ready tickets."), + } + } + + Ok(()) +} + /// Re-serialise all ticket files through the current schema and print a summary. /// /// When `dry_run` is `true`, describe what *would* change without writing any diff --git a/nbd/tests/integration.rs b/nbd/tests/integration.rs index c36e54c..7efb2ff 100644 --- a/nbd/tests/integration.rs +++ b/nbd/tests/integration.rs @@ -963,3 +963,172 @@ fn list_filter_type_and_status_wildcard_includes_done_bugs() { "both bug tickets should appear when status=* is set" ); } + +// ── nbd next tests ──────────────────────────────────────────────────────────── + +/// `nbd next --json` returns the highest-priority ready ticket. +/// +/// Setup: A (pri 5, no deps), B (pri 8, dep A → blocked), C (pri 7, no deps). +/// Expected: C is the highest-priority ready ticket (B is blocked by A). +#[test] +fn next_returns_highest_priority_ready() { + let env = TestEnv::new(); + + let a = env.create(&["--title", "Ticket A", "--priority", "5"]); + env.run(&[ + "create", + "--title", + "Ticket B", + "--priority", + "8", + "--deps", + &a, + "--json", + ]); + env.create(&["--title", "Ticket C", "--priority", "7"]); + + let output = env.run(&["next", "--json"]); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + let parsed: serde_json::Value = + serde_json::from_str(&stdout).expect("next --json should be valid JSON"); + assert!(parsed.get("next").is_some(), "should have 'next' key"); + let ticket = &parsed["next"]; + assert!(!ticket.is_null(), "next should not be null"); + assert_eq!( + ticket["title"], "Ticket C", + "C has the highest priority among ready tickets" + ); +} + +/// After marking the dependency done, a previously blocked ticket becomes next. +#[test] +fn next_updates_after_dep_done() { + let env = TestEnv::new(); + + let a = env.create(&["--title", "Dep A", "--priority", "5"]); + env.run(&[ + "create", + "--title", + "Blocked B", + "--priority", + "8", + "--deps", + &a, + "--json", + ]); + + // Before A is done: A is next (priority 5, the only ready ticket). + let before = env.run(&["next", "--json"]); + let before_str = String::from_utf8(before.stdout).unwrap(); + let before_parsed: serde_json::Value = serde_json::from_str(&before_str).unwrap(); + assert_eq!(before_parsed["next"]["title"], "Dep A"); + + // Mark A done. + env.run(&["update", &a, "--status", "done"]); + + // Now B (priority 8) is next. + let after = env.run(&["next", "--json"]); + let after_str = String::from_utf8(after.stdout).unwrap(); + let after_parsed: serde_json::Value = serde_json::from_str(&after_str).unwrap(); + assert_eq!(after_parsed["next"]["title"], "Blocked B"); +} + +/// When all tickets are done, `nbd next --json` returns `{"next": null}`. +#[test] +fn next_null_when_all_done() { + let env = TestEnv::new(); + + let id = env.create(&["--title", "Only ticket"]); + env.run(&["update", &id, "--status", "done"]); + + let output = env.run(&["next", "--json"]); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert!( + parsed["next"].is_null(), + "next should be null when all are done" + ); +} + +/// `nbd next` without `--json` prints "No ready tickets." and exits 0 when nothing is ready. +#[test] +fn next_no_json_prints_message_when_empty() { + let env = TestEnv::new(); + + let id = env.create(&["--title", "Finished"]); + env.run(&["update", &id, "--status", "done"]); + + let output = env.run(&["next"]); + assert!( + output.status.success(), + "should exit 0 even with no ready tickets" + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!( + stdout.contains("No ready tickets."), + "should print 'No ready tickets.', got: {stdout}" + ); +} + +/// `nbd next --filter type=bug --json` returns the highest-priority ready bug, +/// even when a higher-priority non-bug ticket exists. +#[test] +fn next_filter_by_type() { + let env = TestEnv::new(); + + // Task with priority 9 (highest overall). + env.create(&["--title", "High task", "--priority", "9", "--type", "task"]); + // Bug with priority 8. + env.create(&["--title", "High bug", "--priority", "8", "--type", "bug"]); + + let output = env.run(&["next", "--filter", "type=bug", "--json"]); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!( + parsed["next"]["ticket_type"], "bug", + "filter should restrict to bug tickets" + ); + assert_eq!(parsed["next"]["title"], "High bug"); +} + +/// `nbd next --json` output object always contains an `"id"` field. +#[test] +fn next_json_includes_id_field() { + let env = TestEnv::new(); + + env.create(&["--title", "Has ID"]); + + let output = env.run(&["next", "--json"]); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let ticket = &parsed["next"]; + assert!(!ticket.is_null(), "should find a ready ticket"); + assert!( + ticket.get("id").is_some(), + "ticket object should contain 'id' field" + ); + let id = ticket["id"].as_str().unwrap(); + assert_eq!(id.len(), 6, "id should be 6 characters"); +} + +/// `nbd next --filter priority=99 --json` returns `{"next": null}` when no +/// ticket has the requested priority. +#[test] +fn next_filter_no_match_returns_null() { + let env = TestEnv::new(); + + env.create(&["--title", "Normal ticket", "--priority", "5"]); + + let output = env.run(&["next", "--filter", "priority=99", "--json"]); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert!( + parsed["next"].is_null(), + "no ticket has priority 99 so next should be null" + ); +}