From 3c17918a4762237280a826ca140d391d77662f9d Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Sun, 22 Feb 2026 14:25:21 -0800 Subject: [PATCH] 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 --- nbd/.nbd/tickets/4d2359.json | 3 +- nbd/.nbd/tickets/e1968f.json | 3 +- nbd/README.md | 18 ++++- nbd/src/main.rs | 76 +++++++++++++++++++ nbd/tests/integration.rs | 137 +++++++++++++++++++++++++++++++++++ 5 files changed, 231 insertions(+), 6 deletions(-) diff --git a/nbd/.nbd/tickets/4d2359.json b/nbd/.nbd/tickets/4d2359.json index d4df528..04108b8 100644 --- a/nbd/.nbd/tickets/4d2359.json +++ b/nbd/.nbd/tickets/4d2359.json @@ -1,9 +1,8 @@ { - "id": "4d2359", "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 ` (or JSON: `{\"root\": \"\"}`).\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, - "status": "todo", + "status": "done", "dependencies": [], "ticket_type": "feature" } \ No newline at end of file diff --git a/nbd/.nbd/tickets/e1968f.json b/nbd/.nbd/tickets/e1968f.json index 7005095..7b306d6 100644 --- a/nbd/.nbd/tickets/e1968f.json +++ b/nbd/.nbd/tickets/e1968f.json @@ -1,9 +1,8 @@ { - "id": "e1968f", "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", "priority": 7, - "status": "todo", + "status": "done", "dependencies": [], "ticket_type": "feature" } \ No newline at end of file diff --git a/nbd/README.md b/nbd/README.md index 66f5dde..7bb868a 100644 --- a/nbd/README.md +++ b/nbd/README.md @@ -27,12 +27,14 @@ All commands accept `--json` for machine-readable output. ### Initialise -Create the tickets directory manually in your project root: +Create the ticket store in your project root: ```sh -mkdir -p .nbd/tickets +nbd init ``` +Analogous to `git init` — safe to run multiple times. + ### Create a ticket ```sh @@ -64,6 +66,16 @@ nbd update a3f9c2 --status in_progress 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 Re-serialise all ticket files through the current schema. Use this after @@ -80,8 +92,10 @@ nbd migrate --json # machine-readable summary ```sh # From the nbd/ directory +cargo run -- init cargo run -- create --title "Test ticket" --priority 7 --type bug cargo run -- list +cargo run -- ready cargo run -- read cargo run -- update --status in_progress cargo run -- list --json diff --git a/nbd/src/main.rs b/nbd/src/main.rs index b224368..8cc1c5e 100644 --- a/nbd/src/main.rs +++ b/nbd/src/main.rs @@ -76,6 +76,18 @@ enum Commands { /// List all tickets sorted by priority (highest first). 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. /// /// Brings existing files into conformance with the current data model: @@ -144,6 +156,10 @@ async fn dispatch(cli: Cli) -> store::Result<()> { deps, } => 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::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 ────────────────────────────────────────────────────────── +/// 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::>()); + } else { + display::print_list(&ready.into_iter().cloned().collect::>()); + } + + 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 dbe5a7b..8e9a97e 100644 --- a/nbd/tests/integration.rs +++ b/nbd/tests/integration.rs @@ -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. #[test] fn update_deps_replaces_list() {