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 <noreply@anthropic.com>
quotesdb
Elijah Voigt 3 months ago
parent 8e2fdb5796
commit fa60cb844d

@ -117,7 +117,13 @@ cargo run -- update <id> --status in_progress --json
cargo run -- update <id> --status done --json cargo run -- update <id> --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 ```sh
cargo run -- ready --json cargo run -- ready --json

@ -71,9 +71,23 @@ nbd update a3f9c2 --status in_progress
nbd update a3f9c2 --priority 9 --type bug 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: completed:
```sh ```sh

@ -98,7 +98,7 @@ pub fn print_ticket(ticket: &Ticket) {
/// CLI `--json` output should include `id` so that consumers have all the /// CLI `--json` output should include `id` so that consumers have all the
/// information in one object. This helper re-inserts it into the serialised /// information in one object. This helper re-inserts it into the serialised
/// value. /// 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"); 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. // Re-insert id at the front of the object for ergonomic CLI output.
if let serde_json::Value::Object(ref mut map) = value { if let serde_json::Value::Object(ref mut map) = value {

@ -111,6 +111,20 @@ enum Commands {
filter: Vec<String>, filter: Vec<String>,
}, },
/// 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<String>,
},
/// Re-serialise all ticket files through the current schema. /// Re-serialise all ticket files through the current schema.
/// ///
/// Brings existing files into conformance with the current data model: /// 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::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::Ready { filter } => cmd_ready(filter, cli.json).await,
Commands::Migrate { dry_run, filter } => cmd_migrate(filter, dry_run, 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<String>, json: bool) -> store::Result<()> {
Ok(()) 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<String>, 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. /// 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 /// When `dry_run` is `true`, describe what *would* change without writing any

@ -963,3 +963,172 @@ fn list_filter_type_and_status_wildcard_includes_done_bugs() {
"both bug tickets should appear when status=* is set" "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"
);
}

Loading…
Cancel
Save