feat(nbd): add backlog status to tickets [c12091]

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 <noreply@anthropic.com>
quotesdb
Elijah Voigt 3 months ago
parent 0ba22db39e
commit 7e311d6b47

@ -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
```

@ -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 <id> [--json]

@ -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 010 | `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
```

@ -38,6 +38,7 @@ fn status_str(status: &Status) -> &'static str {
Status::InProgress => "in_progress",
Status::Done => "done",
Status::Closed => "closed",
Status::Backlog => "backlog",
}
}

@ -24,6 +24,7 @@ fn status_str(status: &Status) -> &'static str {
Status::InProgress => "in_progress",
Status::Done => "done",
Status::Closed => "closed",
Status::Backlog => "backlog",
}
}

@ -30,6 +30,7 @@ fn status_str(status: &Status) -> &'static str {
Status::InProgress => "in_progress",
Status::Done => "done",
Status::Closed => "closed",
Status::Backlog => "backlog",
}
}

@ -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<String>,
/// 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<Status> {
"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<String>, 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<String>, 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<String>, 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<String>, 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)
}

@ -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::<Status>("\"closed\"").unwrap(),
Status::Closed
);
assert_eq!(
serde_json::from_str::<Status>("\"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.

@ -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 <id>` 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.

@ -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 <id> --status in_progress` (no `--json`) prints `- status:` and

Loading…
Cancel
Save