feat(nbd): implement nbd init and nbd ready commands [4d2359, e1968f]

- `nbd init` creates `.nbd/tickets/` in cwd (idempotent, no find_nbd_root)
- `nbd ready` lists actionable tickets: not done with all deps completed
- Both commands support `--json` for machine-readable output
- 6 new integration tests covering init and ready behaviour

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
quotesdb
Elijah Voigt 4 months ago
parent 283712770f
commit 3c17918a47

@ -1,9 +1,8 @@
{ {
"id": "4d2359",
"title": "nbd init command", "title": "nbd init command",
"body": "Add an explicit `nbd init` subcommand that creates `.nbd/tickets/` in the current working directory, analogous to `git init`.\n\n## Motivation\n\nCurrently users must run `mkdir -p .nbd/tickets` manually before first use. This is a friction point — especially for first-time users and agent workflows bootstrapping a new project.\n\n## Approach\n\n1. Add `Init` variant to the `Commands` enum in `main.rs`.\n2. Implement `cmd_init(json: bool) -> store::Result<()>`:\n - Get cwd via `std::env::current_dir()`.\n - Call `store::ensure_tickets_dir(&cwd)` (already exists, idempotent).\n - Print confirmation: `initialised .nbd/tickets/ in <path>` (or JSON: `{\"root\": \"<path>\"}`).\n3. No changes to `store.rs` needed — `ensure_tickets_dir` already uses `create_dir_all` (idempotent).\n4. The command should NOT require `.nbd/` to already exist (i.e. do NOT call `find_nbd_root` here — use cwd directly).\n\n## Tests\n\n- Integration test: run `nbd init` in a fresh tempdir, verify `.nbd/tickets/` is created.\n- Integration test: run `nbd init` twice in the same dir — succeeds both times (idempotent).\n- Integration test: `nbd init --json` outputs valid JSON with a `root` field.\n\n## Files touched\n- `src/main.rs` — new `Init` variant and `cmd_init` handler\n- `tests/integration.rs` — new integration tests\n- `README.md` — update Initialise section", "body": "Add an explicit `nbd init` subcommand that creates `.nbd/tickets/` in the current working directory, analogous to `git init`.\n\n## Motivation\n\nCurrently users must run `mkdir -p .nbd/tickets` manually before first use. This is a friction point — especially for first-time users and agent workflows bootstrapping a new project.\n\n## Approach\n\n1. Add `Init` variant to the `Commands` enum in `main.rs`.\n2. Implement `cmd_init(json: bool) -> store::Result<()>`:\n - Get cwd via `std::env::current_dir()`.\n - Call `store::ensure_tickets_dir(&cwd)` (already exists, idempotent).\n - Print confirmation: `initialised .nbd/tickets/ in <path>` (or JSON: `{\"root\": \"<path>\"}`).\n3. No changes to `store.rs` needed — `ensure_tickets_dir` already uses `create_dir_all` (idempotent).\n4. The command should NOT require `.nbd/` to already exist (i.e. do NOT call `find_nbd_root` here — use cwd directly).\n\n## Tests\n\n- Integration test: run `nbd init` in a fresh tempdir, verify `.nbd/tickets/` is created.\n- Integration test: run `nbd init` twice in the same dir — succeeds both times (idempotent).\n- Integration test: `nbd init --json` outputs valid JSON with a `root` field.\n\n## Files touched\n- `src/main.rs` — new `Init` variant and `cmd_init` handler\n- `tests/integration.rs` — new integration tests\n- `README.md` — update Initialise section",
"priority": 7, "priority": 7,
"status": "todo", "status": "done",
"dependencies": [], "dependencies": [],
"ticket_type": "feature" "ticket_type": "feature"
} }

@ -1,9 +1,8 @@
{ {
"id": "e1968f",
"title": "nbd ready command", "title": "nbd ready command",
"body": "Add `nbd ready` subcommand that lists tickets which are actionable right now: not yet done and with all dependencies completed.\n\n## Motivation\n\nAgent workflows need to know which tickets are unblocked. `nbd list` shows everything; `nbd ready` narrows to what can actually be started immediately.\n\n## Approach\n\n1. Add `Ready` variant to `Commands` enum in `main.rs`.\n2. Implement `cmd_ready(json: bool)`:\n a. `list_tickets(root)` to fetch all tickets.\n b. Build a set of IDs for tickets with `status == Status::Done`.\n c. Filter to tickets where:\n - `ticket.status != Status::Done` (not already finished)\n - All IDs in `ticket.dependencies` are in the done-set (or the dep doesn't exist — treat missing deps as unresolved, not ready).\n d. Print the filtered slice using existing `display::print_list` / `print_list_json`.\n3. No new store or display functions needed — reuse existing.\n\n## Edge cases\n- A ticket with no dependencies and status `todo` → ready.\n- A ticket whose dep is `in_progress` → NOT ready.\n- Missing dep ID → NOT ready (treat conservatively).\n- Empty store → returns empty list (not an error).\n\n## Tests\n\nUnit-style integration tests:\n- Three tickets: A (no deps, todo), B (dep A, todo), C (no deps, done). `nbd ready` should return only A.\n- After marking A done, `nbd ready` should return B.\n- `nbd ready --json` returns a JSON array of the ready tickets.\n\n## Files touched\n- `src/main.rs` — new `Ready` variant and `cmd_ready` handler\n- `tests/integration.rs` — new integration tests", "body": "Add `nbd ready` subcommand that lists tickets which are actionable right now: not yet done and with all dependencies completed.\n\n## Motivation\n\nAgent workflows need to know which tickets are unblocked. `nbd list` shows everything; `nbd ready` narrows to what can actually be started immediately.\n\n## Approach\n\n1. Add `Ready` variant to `Commands` enum in `main.rs`.\n2. Implement `cmd_ready(json: bool)`:\n a. `list_tickets(root)` to fetch all tickets.\n b. Build a set of IDs for tickets with `status == Status::Done`.\n c. Filter to tickets where:\n - `ticket.status != Status::Done` (not already finished)\n - All IDs in `ticket.dependencies` are in the done-set (or the dep doesn't exist — treat missing deps as unresolved, not ready).\n d. Print the filtered slice using existing `display::print_list` / `print_list_json`.\n3. No new store or display functions needed — reuse existing.\n\n## Edge cases\n- A ticket with no dependencies and status `todo` → ready.\n- A ticket whose dep is `in_progress` → NOT ready.\n- Missing dep ID → NOT ready (treat conservatively).\n- Empty store → returns empty list (not an error).\n\n## Tests\n\nUnit-style integration tests:\n- Three tickets: A (no deps, todo), B (dep A, todo), C (no deps, done). `nbd ready` should return only A.\n- After marking A done, `nbd ready` should return B.\n- `nbd ready --json` returns a JSON array of the ready tickets.\n\n## Files touched\n- `src/main.rs` — new `Ready` variant and `cmd_ready` handler\n- `tests/integration.rs` — new integration tests",
"priority": 7, "priority": 7,
"status": "todo", "status": "done",
"dependencies": [], "dependencies": [],
"ticket_type": "feature" "ticket_type": "feature"
} }

@ -27,12 +27,14 @@ All commands accept `--json` for machine-readable output.
### Initialise ### Initialise
Create the tickets directory manually in your project root: Create the ticket store in your project root:
```sh ```sh
mkdir -p .nbd/tickets nbd init
``` ```
Analogous to `git init` — safe to run multiple times.
### Create a ticket ### Create a ticket
```sh ```sh
@ -64,6 +66,16 @@ nbd update a3f9c2 --status in_progress
nbd update a3f9c2 --priority 9 --type bug nbd update a3f9c2 --priority 9 --type bug
``` ```
### Find actionable tickets
List tickets that are ready to work on — not done and with all dependencies
completed:
```sh
nbd ready
nbd ready --json
```
### Migrate ticket files ### Migrate ticket files
Re-serialise all ticket files through the current schema. Use this after Re-serialise all ticket files through the current schema. Use this after
@ -80,8 +92,10 @@ nbd migrate --json # machine-readable summary
```sh ```sh
# From the nbd/ directory # From the nbd/ directory
cargo run -- init
cargo run -- create --title "Test ticket" --priority 7 --type bug cargo run -- create --title "Test ticket" --priority 7 --type bug
cargo run -- list cargo run -- list
cargo run -- ready
cargo run -- read <id> cargo run -- read <id>
cargo run -- update <id> --status in_progress cargo run -- update <id> --status in_progress
cargo run -- list --json cargo run -- list --json

@ -76,6 +76,18 @@ enum Commands {
/// List all tickets sorted by priority (highest first). /// List all tickets sorted by priority (highest first).
List, List,
/// Initialise a new `.nbd/tickets/` store in the current directory.
///
/// Analogous to `git init` — safe to run multiple times (idempotent).
Init,
/// List tickets that are ready to work on right now.
///
/// A ticket is ready when its status is not `done` and every ticket it
/// depends on has status `done`. Tickets with no dependencies and status
/// `todo` or `in_progress` are always ready.
Ready,
/// 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:
@ -144,6 +156,10 @@ async fn dispatch(cli: Cli) -> store::Result<()> {
deps, deps,
} => cmd_create(title, body, priority, status, ticket_type, deps, cli.json).await, } => cmd_create(title, body, priority, status, ticket_type, deps, cli.json).await,
Commands::Init => cmd_init(cli.json).await,
Commands::Ready => cmd_ready(cli.json).await,
Commands::Migrate { dry_run } => cmd_migrate(dry_run, cli.json).await, Commands::Migrate { dry_run } => cmd_migrate(dry_run, cli.json).await,
Commands::Read { id } => cmd_read(id, cli.json).await, Commands::Read { id } => cmd_read(id, cli.json).await,
@ -248,6 +264,66 @@ async fn validate_deps(root: &std::path::Path, deps: &mut [String]) -> store::Re
// ── Command handlers ────────────────────────────────────────────────────────── // ── Command handlers ──────────────────────────────────────────────────────────
/// Initialise a `.nbd/tickets/` store in the current working directory.
///
/// Uses `create_dir_all`, so it is safe to call repeatedly (idempotent).
/// Does **not** call [`find_nbd_root`] — the store is always created in cwd.
async fn cmd_init(json: bool) -> store::Result<()> {
let cwd = std::env::current_dir()?;
ensure_tickets_dir(&cwd).await?;
if json {
let path = cwd.join(".nbd").join("tickets");
println!(
"{{\"root\":{}}}",
serde_json::to_string(&path.to_string_lossy())?
);
} else {
println!(
"initialised .nbd/tickets/ in {}",
cwd.join(".nbd").join("tickets").display()
);
}
Ok(())
}
/// List all tickets that are ready to work on and print them.
///
/// A ticket is *ready* when its status is not [`Status::Done`] and every ID in
/// its `dependencies` list belongs to a ticket with `status == Done`.
/// Missing dependency IDs are treated conservatively — the ticket is **not**
/// ready if any dep cannot be resolved.
async fn cmd_ready(json: bool) -> store::Result<()> {
let root = find_nbd_root()?;
let all = list_tickets(&root).await?;
// Build the set of IDs that are fully done.
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 ready: Vec<&crate::ticket::Ticket> = all
.iter()
.filter(|t| {
t.status != crate::ticket::Status::Done
&& t.dependencies
.iter()
.all(|dep| done_ids.contains(dep.as_str()))
})
.collect();
if json {
display::print_list_json(&ready.into_iter().cloned().collect::<Vec<_>>());
} else {
display::print_list(&ready.into_iter().cloned().collect::<Vec<_>>());
}
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

@ -447,6 +447,143 @@ fn ambiguous_prefix_exits_nonzero() {
); );
} }
/// `nbd init` creates `.nbd/tickets/` in a fresh directory.
#[test]
fn init_creates_tickets_dir() {
let tmp = tempfile::tempdir().expect("failed to create tempdir");
let root = tmp.path();
let output = std::process::Command::new(env!("CARGO_BIN_EXE_nbd"))
.args(["init"])
.current_dir(root)
.output()
.expect("failed to spawn nbd");
assert!(
output.status.success(),
"init failed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
root.join(".nbd").join("tickets").is_dir(),
".nbd/tickets/ should be created"
);
}
/// `nbd init` is idempotent — running it twice succeeds.
#[test]
fn init_is_idempotent() {
let tmp = tempfile::tempdir().expect("failed to create tempdir");
let root = tmp.path();
for _ in 0..2 {
let output = std::process::Command::new(env!("CARGO_BIN_EXE_nbd"))
.args(["init"])
.current_dir(root)
.output()
.expect("failed to spawn nbd");
assert!(
output.status.success(),
"init failed on repeat: {}",
String::from_utf8_lossy(&output.stderr)
);
}
assert!(root.join(".nbd").join("tickets").is_dir());
}
/// `nbd init --json` outputs valid JSON with a `root` field.
#[test]
fn init_with_json_flag() {
let tmp = tempfile::tempdir().expect("failed to create tempdir");
let root = tmp.path();
let output = std::process::Command::new(env!("CARGO_BIN_EXE_nbd"))
.args(["init", "--json"])
.current_dir(root)
.output()
.expect("failed to spawn nbd");
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).unwrap();
let parsed: serde_json::Value =
serde_json::from_str(&stdout).expect("--json output should be valid JSON");
assert!(
parsed.get("root").is_some(),
"JSON should have 'root' field, got: {stdout}"
);
}
/// `nbd ready` returns only unblocked, non-done tickets.
#[test]
fn ready_returns_unblocked_tickets() {
let env = TestEnv::new();
// A: no deps, todo → ready
let a = env.create(&["--title", "Ticket A"]);
// B: depends on A, todo → not ready
env.run(&["create", "--title", "Ticket B", "--deps", &a, "--json"]);
// C: no deps, done → not ready (already done)
let c = env.create(&["--title", "Ticket C"]);
env.run(&["update", &c, "--status", "done"]);
let output = env.run(&["ready", "--json"]);
assert!(
output.status.success(),
"ready failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("should be valid JSON");
let arr = parsed.as_array().expect("should be array");
assert_eq!(arr.len(), 1, "only A should be ready");
assert_eq!(arr[0]["title"], "Ticket A");
}
/// `nbd ready` includes ticket B after its dependency A is marked done.
#[test]
fn ready_updates_after_dep_done() {
let env = TestEnv::new();
let a = env.create(&["--title", "Dep A"]);
env.run(&["create", "--title", "Blocked B", "--deps", &a, "--json"]);
// Before marking A done: only A is ready.
let before = env.run(&["ready", "--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.as_array().unwrap().len(), 1);
// Mark A done.
env.run(&["update", &a, "--status", "done"]);
// Now B is ready.
let after = env.run(&["ready", "--json"]);
let after_str = String::from_utf8(after.stdout).unwrap();
let after_parsed: serde_json::Value = serde_json::from_str(&after_str).unwrap();
let arr = after_parsed.as_array().unwrap();
assert_eq!(arr.len(), 1, "only B should be ready now");
assert_eq!(arr[0]["title"], "Blocked B");
}
/// `nbd ready` returns an empty array when no tickets are actionable.
#[test]
fn ready_empty_when_all_done() {
let env = TestEnv::new();
let id = env.create(&["--title", "Finished"]);
env.run(&["update", &id, "--status", "done"]);
let output = env.run(&["ready", "--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.as_array().unwrap().len(),
0,
"no ready tickets when all are done"
);
}
/// `update --deps` replaces the dependency list. /// `update --deps` replaces the dependency list.
#[test] #[test]
fn update_deps_replaces_list() { fn update_deps_replaces_list() {

Loading…
Cancel
Save