From 7e311d6b47738bcca21cc2c8ef918de136da4e0c Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Mon, 23 Feb 2026 10:09:15 -0800 Subject: [PATCH] feat(nbd): add backlog status to tickets [c12091] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new `backlog` Status variant for tickets that are created but intentionally deferred. Backlog tickets are excluded from `nbd list`, `nbd ready`, and `nbd next` by default, but unlike `done` and `closed` they do not count as resolved for dependency purposes — a ticket whose dependency is `backlog` remains blocked. Visible via `--all` or `--filter status=backlog`. Co-Authored-By: Claude Sonnet 4.6 --- nbd/.nbd/tickets/c12091.md | 80 ++++++++++++++++ nbd/CLAUDE.md | 2 +- nbd/README.md | 17 ++-- nbd/src/display.rs | 1 + nbd/src/filter.rs | 1 + nbd/src/graph.rs | 1 + nbd/src/main.rs | 36 ++++--- nbd/src/tests.rs | 23 ++++- nbd/src/ticket.rs | 12 ++- nbd/tests/integration.rs | 188 +++++++++++++++++++++++++++++++++++++ 10 files changed, 335 insertions(+), 26 deletions(-) create mode 100644 nbd/.nbd/tickets/c12091.md diff --git a/nbd/.nbd/tickets/c12091.md b/nbd/.nbd/tickets/c12091.md new file mode 100644 index 0000000..f785429 --- /dev/null +++ b/nbd/.nbd/tickets/c12091.md @@ -0,0 +1,80 @@ ++++ +title = "Add backlog status to tickets" +priority = 5 +status = "done" +ticket_type = "feature" +dependencies = [] ++++ +## Goal + +Add a `backlog` status variant so tickets can be created without immediately surfacing in the active work queue. Tickets in `backlog` are created but intentionally deferred; they should not appear in `nbd list`, `nbd ready`, or `nbd next` by default. + +## Semantics + +- `backlog` = created, but not yet ready to be worked on (intentionally deferred) +- Default status for new tickets remains `todo` +- `backlog` tickets are excluded from `nbd list` (same as `done` and `closed`) +- `backlog` tickets are excluded from `nbd ready` and `nbd next` +- `backlog` tickets do **not** count as resolved for dependency purposes (unlike `done` and `closed`) — a dependency on a `backlog` ticket still blocks +- `backlog` tickets are visible with `--all`, `--filter status=backlog`, or `--filter status=*` + +## Files to change + +### `src/ticket.rs` + +Add `Backlog` variant to the `Status` enum: + +```rust +pub enum Status { + #[default] + Todo, + InProgress, + Done, + Closed, + Backlog, // new +} +``` + +Serialises as `"backlog"`. + +### `src/main.rs` + +- `parse_status`: add `"backlog" => Ok(Status::Backlog)` arm and update the error message +- `cmd_list`: exclude `Status::Backlog` in the default (no `--all`, no `status=` filter) case, alongside `Done` and `Closed` +- `cmd_ready`: exclude `Status::Backlog` from the ready set (a backlog ticket is never ready) +- `cmd_next`: same exclusion as `cmd_ready` + +### `src/display.rs` + +- `status_str`: add `Status::Backlog => "backlog"` arm + +### `src/graph.rs` + +- `status_str` (internal helper, duplicated): add `Status::Backlog => "backlog"` arm + +### README.md + +- Update the Status table to include `backlog` +- Update `nbd list` usage examples to mention that `backlog` is also hidden by default + +### CLAUDE.md + +- Update CLI Interface block to show `backlog` as a valid status value + +### `src/tests.rs` + +- Add unit test: a ticket with `Status::Backlog` is excluded from the ready list and is visible under `--all` + +### `tests/integration.rs` + +- Add integration test: create a ticket with `--status backlog`, confirm it does not appear in plain `nbd list` or `nbd ready`, and does appear in `nbd list --all` + +## Validation + +```sh +cargo fmt && cargo check && cargo clippy && cargo test +cargo run -- create --title "Backlog item" --status backlog --json +cargo run -- list --json # should NOT appear +cargo run -- list --all --json # should appear +cargo run -- ready --json # should NOT appear +``` \ No newline at end of file diff --git a/nbd/CLAUDE.md b/nbd/CLAUDE.md index ec35fb6..f0b801a 100644 --- a/nbd/CLAUDE.md +++ b/nbd/CLAUDE.md @@ -53,7 +53,7 @@ Ticket { ```sh nbd init [--json] -nbd create --title "..." [--body "..."] [--priority 5] [--status todo] +nbd create --title "..." [--body "..."] [--priority 5] [--status todo|in_progress|done|closed|backlog] [--type task] [--deps id1,id2] [--json] nbd read [--json] diff --git a/nbd/README.md b/nbd/README.md index 0b46c4a..f92c1b8 100644 --- a/nbd/README.md +++ b/nbd/README.md @@ -17,7 +17,7 @@ Each ticket is a JSON file named `{id}.json`, where `id` is a unique | `title` | string | *(required)* | | `body` | string | `""` | | `priority` | integer 0–10 | `5` | -| `status` | `todo` \| `in_progress` \| `done` \| `closed` | `todo` | +| `status` | `todo` \| `in_progress` \| `done` \| `closed` \| `backlog` | `todo` | | `ticket_type` | `project` \| `feature` \| `task` \| `bug` | `task` | | `dependencies` | list of ticket IDs | `[]` | @@ -63,15 +63,16 @@ nbd read a3f9c2 --json ### List all tickets -By default, `done` and `closed` tickets are excluded. +By default, `done`, `closed`, and `backlog` tickets are excluded. ```sh -nbd list # todo + in_progress only (done + closed excluded) -nbd list --all # all tickets including done and closed -nbd list --filter status=* # all tickets (alias for --all via filter) -nbd list --filter status=done # only completed tickets -nbd list --filter status=closed # only archived tickets -nbd list --filter type=bug # non-done, non-closed bug tickets +nbd list # todo + in_progress only (done/closed/backlog excluded) +nbd list --all # all tickets including done, closed, and backlog +nbd list --filter status=* # all tickets (alias for --all via filter) +nbd list --filter status=done # only completed tickets +nbd list --filter status=closed # only archived tickets +nbd list --filter status=backlog # only backlog tickets +nbd list --filter type=bug # non-done, non-closed, non-backlog bug tickets nbd list --json ``` diff --git a/nbd/src/display.rs b/nbd/src/display.rs index 3ab2208..3dcd146 100644 --- a/nbd/src/display.rs +++ b/nbd/src/display.rs @@ -38,6 +38,7 @@ fn status_str(status: &Status) -> &'static str { Status::InProgress => "in_progress", Status::Done => "done", Status::Closed => "closed", + Status::Backlog => "backlog", } } diff --git a/nbd/src/filter.rs b/nbd/src/filter.rs index 1675e69..88b4ebf 100644 --- a/nbd/src/filter.rs +++ b/nbd/src/filter.rs @@ -24,6 +24,7 @@ fn status_str(status: &Status) -> &'static str { Status::InProgress => "in_progress", Status::Done => "done", Status::Closed => "closed", + Status::Backlog => "backlog", } } diff --git a/nbd/src/graph.rs b/nbd/src/graph.rs index 0457612..e7caf46 100644 --- a/nbd/src/graph.rs +++ b/nbd/src/graph.rs @@ -30,6 +30,7 @@ fn status_str(status: &Status) -> &'static str { Status::InProgress => "in_progress", Status::Done => "done", Status::Closed => "closed", + Status::Backlog => "backlog", } } diff --git a/nbd/src/main.rs b/nbd/src/main.rs index 0f23286..be1c2a5 100644 --- a/nbd/src/main.rs +++ b/nbd/src/main.rs @@ -91,9 +91,9 @@ enum Commands { /// List tickets sorted by priority (highest first). /// - /// By default, tickets with status `done` or `closed` are excluded. Pass - /// `--all` to include all tickets, or `--filter status=*` to override only - /// the status exclusion. + /// By default, tickets with status `done`, `closed`, or `backlog` are + /// excluded. Pass `--all` to include all tickets, or `--filter status=*` + /// to override only the status exclusion. List { /// Filter tickets by field: repeatable `key=value` pairs. /// @@ -101,13 +101,14 @@ enum Commands { /// Different keys are ANDed; the same key with multiple values is ORed. /// Values support glob wildcards: `title=*login*`. /// - /// If no `status` filter is provided, tickets with status `done` or - /// `closed` are excluded automatically. Provide `--filter status=*` - /// to override, or use `--all`. + /// If no `status` filter is provided, tickets with status `done`, + /// `closed`, or `backlog` are excluded automatically. Provide + /// `--filter status=*` to override, or use `--all`. #[arg(long = "filter", value_name = "KEY=VALUE")] filter: Vec, - /// Show all tickets regardless of status, including `done` and `closed`. + /// Show all tickets regardless of status, including `done`, `closed`, + /// and `backlog`. /// /// Equivalent to `--filter status=*` but takes precedence over any /// `--filter` arguments when both are supplied. @@ -344,7 +345,7 @@ async fn dispatch(cli: Cli) -> store::Result<()> { /// Parse a [`Status`] from its lowercase string representation. /// -/// Accepts `"todo"`, `"in_progress"`, `"done"`, and `"closed"`. +/// Accepts `"todo"`, `"in_progress"`, `"done"`, `"closed"`, and `"backlog"`. /// /// # Errors /// @@ -355,8 +356,9 @@ fn parse_status(s: &str) -> store::Result { "in_progress" => Ok(Status::InProgress), "done" => Ok(Status::Done), "closed" => Ok(Status::Closed), + "backlog" => Ok(Status::Backlog), other => Err(format!( - "unknown status '{other}'; expected 'todo', 'in_progress', 'done', or 'closed'" + "unknown status '{other}'; expected 'todo', 'in_progress', 'done', 'closed', or 'backlog'" ) .into()), } @@ -480,6 +482,7 @@ async fn cmd_ready(filter_args: Vec, json: bool) -> store::Result<()> { .filter(|t| { t.status != crate::ticket::Status::Done && t.status != crate::ticket::Status::Closed + && t.status != crate::ticket::Status::Backlog && t.dependencies .iter() .all(|dep| done_ids.contains(dep.as_str())) @@ -521,6 +524,7 @@ async fn cmd_next(filter_args: Vec, json: bool) -> store::Result<()> { let next = all.iter().find(|t| { t.status != crate::ticket::Status::Done && t.status != crate::ticket::Status::Closed + && t.status != crate::ticket::Status::Backlog && t.dependencies .iter() .all(|dep| done_ids.contains(dep.as_str())) @@ -631,11 +635,11 @@ async fn cmd_read(id: String, json: bool) -> store::Result<()> { /// List tickets sorted by priority and print them. /// /// `filter_args` are optional `key=value` expressions that narrow the output. -/// `all` bypasses the default done/closed exclusion. +/// `all` bypasses the default done/closed/backlog exclusion. /// -/// **Default behaviour:** tickets with status [`Status::Done`] or -/// [`Status::Closed`] are excluded unless the caller provides at least one -/// `status=…` filter argument or passes `--all`. +/// **Default behaviour:** tickets with status [`Status::Done`], +/// [`Status::Closed`], or [`Status::Backlog`] are excluded unless the caller +/// provides at least one `status=…` filter argument or passes `--all`. /// Pass `--filter status=*` or `--all` to see every ticket. async fn cmd_list(filter_args: Vec, all: bool, json: bool) -> store::Result<()> { let filter = crate::filter::parse_filters(&filter_args)?; @@ -648,11 +652,13 @@ async fn cmd_list(filter_args: Vec, all: bool, json: bool) -> store::Res // --all: skip status exclusions; apply every other filter. filter.matches(t) } else { - // If no status filter was provided, exclude done and closed tickets by default. + // If no status filter was provided, exclude done, closed, and backlog tickets by default. let status_ok = if filter.has_status_filter() { filter.matches_status(t) } else { - t.status != Status::Done && t.status != Status::Closed + t.status != Status::Done + && t.status != Status::Closed + && t.status != Status::Backlog }; status_ok && filter.matches_except_status(t) } diff --git a/nbd/src/tests.rs b/nbd/src/tests.rs index f2d091e..2c70cf3 100644 --- a/nbd/src/tests.rs +++ b/nbd/src/tests.rs @@ -76,6 +76,10 @@ mod ticket { serde_json::to_string(&Status::Closed).unwrap(), "\"closed\"" ); + assert_eq!( + serde_json::to_string(&Status::Backlog).unwrap(), + "\"backlog\"" + ); } /// `Status` deserialises correctly from lowercase snake_case strings. @@ -97,6 +101,10 @@ mod ticket { serde_json::from_str::("\"closed\"").unwrap(), Status::Closed ); + assert_eq!( + serde_json::from_str::("\"backlog\"").unwrap(), + Status::Backlog + ); } /// `TicketType` variants serialise to the expected lowercase strings. @@ -937,7 +945,7 @@ mod filter { assert!(!filter.matches_status(&ticket)); } - /// `matches_status` with `status=*` matches any status including `closed`. + /// `matches_status` with `status=*` matches any status including `closed` and `backlog`. #[test] fn matches_status_wildcard_matches_all() { let args = vec!["status=*".to_string()]; @@ -946,6 +954,19 @@ mod filter { assert!(filter.matches_status(&make_ticket(Status::InProgress, TicketType::Task, 5, "T"))); assert!(filter.matches_status(&make_ticket(Status::Done, TicketType::Task, 5, "T"))); assert!(filter.matches_status(&make_ticket(Status::Closed, TicketType::Task, 5, "T"))); + assert!(filter.matches_status(&make_ticket(Status::Backlog, TicketType::Task, 5, "T"))); + } + + /// `matches_status` with `status=backlog` matches only backlog tickets. + #[test] + fn matches_status_backlog_pattern() { + let args = vec!["status=backlog".to_string()]; + let filter = parse_filters(&args).unwrap(); + assert!(!filter.matches_status(&make_ticket(Status::Todo, TicketType::Task, 5, "T"))); + assert!(!filter.matches_status(&make_ticket(Status::InProgress, TicketType::Task, 5, "T"))); + assert!(!filter.matches_status(&make_ticket(Status::Done, TicketType::Task, 5, "T"))); + assert!(!filter.matches_status(&make_ticket(Status::Closed, TicketType::Task, 5, "T"))); + assert!(filter.matches_status(&make_ticket(Status::Backlog, TicketType::Task, 5, "T"))); } /// `matches_status` with `status=closed` matches only closed tickets. diff --git a/nbd/src/ticket.rs b/nbd/src/ticket.rs index 0be2712..4215ff0 100644 --- a/nbd/src/ticket.rs +++ b/nbd/src/ticket.rs @@ -14,7 +14,8 @@ use serde::{Deserialize, Serialize}; /// The lifecycle status of a ticket. /// /// Serializes to/from lowercase snake_case strings so that JSON files are -/// human-readable: `"todo"`, `"in_progress"`, `"done"`, `"closed"`. +/// human-readable: `"todo"`, `"in_progress"`, `"done"`, `"closed"`, +/// `"backlog"`. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] #[serde(rename_all = "snake_case")] pub enum Status { @@ -31,6 +32,15 @@ pub enum Status { /// Use `nbd archive ` to close a ticket, or pass `--filter status=closed` /// (or `--all`) to make them visible again. Closed, + /// The ticket is created but intentionally deferred. + /// + /// Backlog tickets are excluded from `nbd list`, `nbd ready`, and + /// `nbd next` by default. Unlike `done` and `closed`, they do **not** + /// count as resolved for dependency purposes — a dependent ticket whose + /// dependency is `backlog` is still blocked. + /// + /// Use `--filter status=backlog` or `--all` to make them visible. + Backlog, } /// The category of a ticket. diff --git a/nbd/tests/integration.rs b/nbd/tests/integration.rs index b84d2c6..887511c 100644 --- a/nbd/tests/integration.rs +++ b/nbd/tests/integration.rs @@ -1819,6 +1819,194 @@ fn graph_partial_id() { ); } +// ── nbd backlog status tests ────────────────────────────────────────────────── + +/// `nbd create --status backlog` creates a ticket with status `backlog`. +#[test] +fn create_with_backlog_status() { + let env = TestEnv::new(); + + let output = env.run(&[ + "create", + "--title", + "Deferred task", + "--status", + "backlog", + "--json", + ]); + assert!( + output.status.success(), + "create --status backlog 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"); + assert_eq!(parsed["status"], "backlog", "status should be backlog"); +} + +/// `nbd list` does not show backlog tickets by default. +#[test] +fn list_excludes_backlog_by_default() { + let env = TestEnv::new(); + + let active_id = env.create(&["--title", "Active ticket"]); + env.run(&[ + "create", + "--title", + "Backlog ticket", + "--status", + "backlog", + "--json", + ]); + + let output = env.run(&["list", "--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 arr = parsed.as_array().unwrap(); + assert_eq!(arr.len(), 1, "only the active ticket should appear"); + assert_eq!(arr[0]["id"], active_id); +} + +/// `nbd list --all` includes backlog tickets. +#[test] +fn list_all_includes_backlog() { + let env = TestEnv::new(); + + env.create(&["--title", "Active ticket"]); + env.run(&[ + "create", + "--title", + "Backlog ticket", + "--status", + "backlog", + "--json", + ]); + + let output = env.run(&["list", "--all", "--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 arr = parsed.as_array().unwrap(); + assert_eq!( + arr.len(), + 2, + "--all should show all tickets including backlog" + ); +} + +/// `nbd list --filter status=backlog` shows only backlog tickets. +#[test] +fn list_filter_status_backlog_shows_only_backlog() { + let env = TestEnv::new(); + + env.create(&["--title", "Active ticket"]); + let backlog_output = env.run(&[ + "create", + "--title", + "Backlog ticket", + "--status", + "backlog", + "--json", + ]); + let backlog_stdout = String::from_utf8(backlog_output.stdout).unwrap(); + let backlog_parsed: serde_json::Value = serde_json::from_str(&backlog_stdout).unwrap(); + let backlog_id = backlog_parsed["id"].as_str().unwrap().to_string(); + + let output = env.run(&["list", "--filter", "status=backlog", "--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 arr = parsed.as_array().unwrap(); + assert_eq!(arr.len(), 1, "only the backlog ticket should appear"); + assert_eq!(arr[0]["id"], backlog_id); +} + +/// `nbd ready` does not include backlog tickets. +#[test] +fn ready_excludes_backlog_tickets() { + let env = TestEnv::new(); + + env.run(&[ + "create", + "--title", + "Backlog item", + "--status", + "backlog", + "--json", + ]); + + 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(); + let arr = parsed.as_array().unwrap(); + assert_eq!(arr.len(), 0, "backlog tickets should not appear in ready"); +} + +/// A backlog dependency still blocks a dependent ticket (backlog is NOT resolved). +#[test] +fn backlog_dep_blocks_dependent() { + let env = TestEnv::new(); + + let dep_output = env.run(&[ + "create", + "--title", + "Backlog dep", + "--status", + "backlog", + "--json", + ]); + let dep_stdout = String::from_utf8(dep_output.stdout).unwrap(); + let dep_parsed: serde_json::Value = serde_json::from_str(&dep_stdout).unwrap(); + let dep_id = dep_parsed["id"].as_str().unwrap().to_string(); + + env.run(&[ + "create", + "--title", + "Dependent", + "--deps", + &dep_id, + "--json", + ]); + + // The dependent should NOT be ready because its dep is backlog (not resolved). + 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(); + let arr = parsed.as_array().unwrap(); + assert_eq!( + arr.len(), + 0, + "dependent should be blocked when dependency is backlog" + ); +} + +/// `nbd next --json` does not return backlog tickets. +#[test] +fn next_excludes_backlog_tickets() { + let env = TestEnv::new(); + + env.run(&[ + "create", + "--title", + "Backlog item", + "--status", + "backlog", + "--json", + ]); + + 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(), + "backlog ticket should not appear as next: {stdout}" + ); +} + // ── update diff output tests ────────────────────────────────────────────────── /// `nbd update --status in_progress` (no `--json`) prints `- status:` and