feat(nbd): split archive/closed — archive=archived, closed=cancelled [feb901]

`nbd archive` now sets status to `archived` (completed, soft-deleted)
instead of `closed`. The `closed` status is reserved for tickets that
will not be completed (cancelled, superseded, won't-fix).

Both statuses count as resolved for dependency purposes and are excluded
from `nbd list`, `nbd ready`, and `nbd next` by default.

Changes:
- Add `Status::Archived` variant (serialises as "archived")
- `cmd_archive`: sets `Status::Archived` instead of `Status::Closed`
- `parse_status`: add "archived" arm
- `cmd_list`, `cmd_ready`, `cmd_next`: exclude/resolve `Archived`
- `display`, `graph`, `filter`: add `Archived` arm to `status_str`
- Tests: rename/update archive tests, add archived/closed test variants
- Docs: update README.md and CLAUDE.md status tables and descriptions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
quotesdb
Elijah Voigt 3 months ago
parent 9d63d76198
commit 05beff7752

@ -42,7 +42,7 @@ Ticket {
title: String
body: String
priority: u8 // 0..=10, default 5
status: Status // Todo | InProgress | Done, default Todo
status: Status // Todo | InProgress | Done | Closed | Archived | Backlog, default Todo
dependencies: Vec<String> // Vec of ticket IDs, default []
ticket_type: TicketType // Project | Feature | Task | Bug, default Task
}
@ -53,7 +53,7 @@ Ticket {
```sh
nbd init [--json]
nbd create --title "..." [--body "..."] [--priority 5] [--status todo|in_progress|done|closed|backlog]
nbd create --title "..." [--body "..."] [--priority 5] [--status todo|in_progress|done|closed|archived|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` \| `backlog` | `todo` |
| `status` | `todo` \| `in_progress` \| `done` \| `closed` \| `archived` \| `backlog` | `todo` |
| `ticket_type` | `project` \| `feature` \| `task` \| `bug` | `task` |
| `dependencies` | list of ticket IDs | `[]` |
@ -63,22 +63,23 @@ nbd read a3f9c2 --json
### List all tickets
By default, `done`, `closed`, and `backlog` tickets are excluded.
By default, `done`, `closed`, `archived`, and `backlog` tickets are excluded.
```sh
nbd list # todo + in_progress only (done/closed/backlog excluded)
nbd list --all # all tickets including done, closed, and backlog
nbd list # todo + in_progress only (done/closed/archived/backlog excluded)
nbd list --all # all tickets including done, closed, archived, 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=archived # only archived tickets (set by nbd archive)
nbd list --filter status=closed # only closed/cancelled tickets
nbd list --filter status=backlog # only backlog tickets
nbd list --filter type=bug # non-done, non-closed, non-backlog bug tickets
nbd list --filter type=bug # non-done, non-closed, non-archived, non-backlog bug tickets
nbd list --json
```
### Archive a ticket
Soft-delete a ticket by setting its status to `closed`. The file is preserved
Soft-delete a ticket by setting its status to `archived`. The file is preserved
on disk but hidden from normal listings.
```sh
@ -86,9 +87,14 @@ nbd archive a3f9c2
nbd archive a3f9c2 --json
```
`nbd archive` is shorthand for `nbd update <id> --status closed`. Archived
tickets still count as resolved for dependency purposes — any ticket that
depends on an archived ticket becomes unblocked.
`nbd archive` is shorthand for `nbd update <id> --status archived`. Archived
tickets count as resolved for dependency purposes — any ticket that depends on
an archived ticket becomes unblocked.
The `closed` status is distinct from `archived`: use `closed` (via
`nbd update <id> --status closed`) for tickets that will not be completed
(cancelled, superseded, won't-fix). Both `archived` and `closed` are excluded
from normal listings and both count as resolved for dependencies.
### Update a ticket

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

@ -91,9 +91,9 @@ enum Commands {
/// List tickets sorted by priority (highest first).
///
/// 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.
/// By default, tickets with status `done`, `closed`, `archived`, 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.
///
@ -102,13 +102,13 @@ enum Commands {
/// Values support glob wildcards: `title=*login*`.
///
/// If no `status` filter is provided, tickets with status `done`,
/// `closed`, or `backlog` are excluded automatically. Provide
/// `--filter status=*` to override, or use `--all`.
/// `closed`, `archived`, 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`, `closed`,
/// and `backlog`.
/// `archived`, and `backlog`.
///
/// Equivalent to `--filter status=*` but takes precedence over any
/// `--filter` arguments when both are supplied.
@ -167,13 +167,13 @@ enum Commands {
filter: Vec<String>,
},
/// Archive a ticket by setting its status to `closed`.
/// Archive a ticket by setting its status to `archived`.
///
/// The ticket is preserved on disk but excluded from normal `nbd list`
/// output. Use `nbd list --all` or `--filter status=closed` to view
/// archived tickets.
/// output. Archived tickets count as resolved for dependency purposes.
/// Use `nbd list --all` or `--filter status=archived` to view them.
///
/// This is syntactic sugar for `nbd update <id> --status closed`.
/// This is syntactic sugar for `nbd update <id> --status archived`.
Archive {
/// The 6-character hex ticket ID to archive.
id: String,
@ -345,7 +345,8 @@ async fn dispatch(cli: Cli) -> store::Result<()> {
/// Parse a [`Status`] from its lowercase string representation.
///
/// Accepts `"todo"`, `"in_progress"`, `"done"`, `"closed"`, and `"backlog"`.
/// Accepts `"todo"`, `"in_progress"`, `"done"`, `"closed"`, `"archived"`,
/// and `"backlog"`.
///
/// # Errors
///
@ -356,9 +357,10 @@ fn parse_status(s: &str) -> store::Result<Status> {
"in_progress" => Ok(Status::InProgress),
"done" => Ok(Status::Done),
"closed" => Ok(Status::Closed),
"archived" => Ok(Status::Archived),
"backlog" => Ok(Status::Backlog),
other => Err(format!(
"unknown status '{other}'; expected 'todo', 'in_progress', 'done', 'closed', or 'backlog'"
"unknown status '{other}'; expected 'todo', 'in_progress', 'done', 'closed', 'archived', or 'backlog'"
)
.into()),
}
@ -467,12 +469,14 @@ async fn cmd_ready(filter_args: Vec<String>, json: bool) -> store::Result<()> {
let root = find_nbd_root()?;
let all = list_tickets(&root).await?;
// Build the set of IDs that are resolved (done or closed).
// Closed tickets count as resolved for dependency purposes.
// Build the set of IDs that are resolved (done, closed, or archived).
// Both closed and archived tickets count as resolved for dependency purposes.
let done_ids: std::collections::HashSet<&str> = all
.iter()
.filter(|t| {
t.status == crate::ticket::Status::Done || t.status == crate::ticket::Status::Closed
t.status == crate::ticket::Status::Done
|| t.status == crate::ticket::Status::Closed
|| t.status == crate::ticket::Status::Archived
})
.map(|t| t.id.as_str())
.collect();
@ -482,6 +486,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::Archived
&& t.status != crate::ticket::Status::Backlog
&& t.dependencies
.iter()
@ -516,7 +521,9 @@ async fn cmd_next(filter_args: Vec<String>, json: bool) -> store::Result<()> {
let done_ids: std::collections::HashSet<&str> = all
.iter()
.filter(|t| {
t.status == crate::ticket::Status::Done || t.status == crate::ticket::Status::Closed
t.status == crate::ticket::Status::Done
|| t.status == crate::ticket::Status::Closed
|| t.status == crate::ticket::Status::Archived
})
.map(|t| t.id.as_str())
.collect();
@ -524,6 +531,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::Archived
&& t.status != crate::ticket::Status::Backlog
&& t.dependencies
.iter()
@ -635,12 +643,12 @@ 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/backlog exclusion.
/// `all` bypasses the default done/closed/archived/backlog exclusion.
///
/// **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.
/// [`Status::Closed`], [`Status::Archived`], 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)?;
let root = find_nbd_root()?;
@ -652,12 +660,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, closed, and backlog tickets by default.
// If no status filter was provided, exclude done, closed, archived, 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::Archived
&& t.status != Status::Backlog
};
status_ok && filter.matches_except_status(t)
@ -674,18 +683,19 @@ async fn cmd_list(filter_args: Vec<String>, all: bool, json: bool) -> store::Res
Ok(())
}
/// Archive a ticket by setting its status to [`Status::Closed`] and printing it.
/// Archive a ticket by setting its status to [`Status::Archived`] and printing it.
///
/// The ticket is preserved on disk but excluded from normal `nbd list` output.
/// The file is re-written in its existing format. This is syntactic sugar for
/// `nbd update <id> --status closed`.
/// Archived tickets count as resolved for dependency purposes, unblocking any
/// dependents. The file is re-written in its existing format. This is syntactic
/// sugar for `nbd update <id> --status archived`.
async fn cmd_archive(id: String, json: bool) -> store::Result<()> {
let root = find_nbd_root()?;
let id = resolve_id(&root, &id).await?;
let existing_path = find_ticket_path(&root, &id).await?;
let format = detect_format(&existing_path);
let mut ticket = read_ticket(&root, &id).await?;
ticket.status = Status::Closed;
ticket.status = Status::Archived;
write_ticket(&root, &ticket, format).await?;
if json {

@ -76,6 +76,10 @@ mod ticket {
serde_json::to_string(&Status::Closed).unwrap(),
"\"closed\""
);
assert_eq!(
serde_json::to_string(&Status::Archived).unwrap(),
"\"archived\""
);
assert_eq!(
serde_json::to_string(&Status::Backlog).unwrap(),
"\"backlog\""
@ -101,6 +105,10 @@ mod ticket {
serde_json::from_str::<Status>("\"closed\"").unwrap(),
Status::Closed
);
assert_eq!(
serde_json::from_str::<Status>("\"archived\"").unwrap(),
Status::Archived
);
assert_eq!(
serde_json::from_str::<Status>("\"backlog\"").unwrap(),
Status::Backlog

@ -15,7 +15,7 @@ use serde::{Deserialize, Serialize};
///
/// Serializes to/from lowercase snake_case strings so that JSON files are
/// human-readable: `"todo"`, `"in_progress"`, `"done"`, `"closed"`,
/// `"backlog"`.
/// `"archived"`, `"backlog"`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
#[serde(rename_all = "snake_case")]
pub enum Status {
@ -26,18 +26,29 @@ pub enum Status {
InProgress,
/// The ticket has been completed.
Done,
/// The ticket has been archived (soft-deleted).
/// The ticket will not be completed (cancelled, superseded, won't-fix).
///
/// Closed tickets are preserved on disk but excluded from normal listings.
/// Use `nbd archive <id>` to close a ticket, or pass `--filter status=closed`
/// (or `--all`) to make them visible again.
/// Closed tickets count as resolved for dependency purposes — a dependent
/// ticket whose dependency is `closed` becomes unblocked. They are
/// excluded from normal `nbd list` output.
///
/// Use `nbd update <id> --status closed` to set this status, or pass
/// `--filter status=closed` (or `--all`) to make them visible in listings.
Closed,
/// The ticket was completed and soft-deleted from the active view.
///
/// Set by `nbd archive <id>`. Archived tickets count as resolved for
/// dependency purposes — a dependent ticket whose dependency is `archived`
/// becomes unblocked. They are excluded from normal `nbd list` output.
///
/// Use `--filter status=archived` or `--all` to make them visible again.
Archived,
/// 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.
/// `nbd next` by default. Unlike `done`, `closed`, and `archived`, 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,

@ -966,9 +966,9 @@ fn list_filter_type_and_status_wildcard_includes_done_bugs() {
// ── nbd archive tests ─────────────────────────────────────────────────────────
/// `nbd archive <id>` sets the ticket status to `closed`.
/// `nbd archive <id>` sets the ticket status to `archived`.
#[test]
fn archive_sets_status_closed() {
fn archive_sets_status_archived() {
let env = TestEnv::new();
let id = env.create(&["--title", "Archive me"]);
@ -982,11 +982,29 @@ fn archive_sets_status_closed() {
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"], "closed",
"archive should set status to closed"
parsed["status"], "archived",
"archive should set status to archived"
);
}
/// `nbd list` does not show archived tickets by default.
#[test]
fn list_excludes_archived_by_default() {
let env = TestEnv::new();
let todo_id = env.create(&["--title", "Active ticket"]);
let archived_id = env.create(&["--title", "Archived ticket"]);
env.run(&["archive", &archived_id]);
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"], todo_id);
}
/// `nbd list` does not show closed tickets by default.
#[test]
fn list_excludes_closed_by_default() {
@ -994,7 +1012,7 @@ fn list_excludes_closed_by_default() {
let todo_id = env.create(&["--title", "Active ticket"]);
let closed_id = env.create(&["--title", "Closed ticket"]);
env.run(&["archive", &closed_id]);
env.run(&["update", &closed_id, "--status", "closed"]);
let output = env.run(&["list", "--json"]);
assert!(output.status.success());
@ -1005,14 +1023,16 @@ fn list_excludes_closed_by_default() {
assert_eq!(arr[0]["id"], todo_id);
}
/// `nbd list --all` includes closed tickets.
/// `nbd list --all` includes archived and closed tickets.
#[test]
fn list_all_includes_closed() {
fn list_all_includes_archived_and_closed() {
let env = TestEnv::new();
env.create(&["--title", "Active ticket"]);
let archived_id = env.create(&["--title", "Archived ticket"]);
env.run(&["archive", &archived_id]);
let closed_id = env.create(&["--title", "Closed ticket"]);
env.run(&["archive", &closed_id]);
env.run(&["update", &closed_id, "--status", "closed"]);
let output = env.run(&["list", "--all", "--json"]);
assert!(output.status.success());
@ -1021,11 +1041,29 @@ fn list_all_includes_closed() {
let arr = parsed.as_array().unwrap();
assert_eq!(
arr.len(),
2,
"--all should show all tickets including closed"
3,
"--all should show all tickets including archived and closed"
);
}
/// `nbd list --filter status=archived` shows only archived tickets.
#[test]
fn list_filter_status_archived_shows_only_archived() {
let env = TestEnv::new();
env.create(&["--title", "Active ticket"]);
let archived_id = env.create(&["--title", "Archived ticket"]);
env.run(&["archive", &archived_id]);
let output = env.run(&["list", "--filter", "status=archived", "--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 archived ticket should appear");
assert_eq!(arr[0]["id"], archived_id);
}
/// `nbd list --filter status=closed` shows only closed tickets.
#[test]
fn list_filter_status_closed_shows_only_closed() {
@ -1033,7 +1071,7 @@ fn list_filter_status_closed_shows_only_closed() {
env.create(&["--title", "Active ticket"]);
let closed_id = env.create(&["--title", "Closed ticket"]);
env.run(&["archive", &closed_id]);
env.run(&["update", &closed_id, "--status", "closed"]);
let output = env.run(&["list", "--filter", "status=closed", "--json"]);
assert!(output.status.success());
@ -1060,7 +1098,32 @@ fn archive_accepts_prefix() {
);
let stdout = String::from_utf8(output.stdout).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(parsed["status"], "closed");
assert_eq!(parsed["status"], "archived");
}
/// An archived dependency unblocks dependent tickets (archived counts as resolved).
#[test]
fn archived_dep_unblocks_dependent() {
let env = TestEnv::new();
let dep = env.create(&["--title", "Dep ticket"]);
env.run(&["create", "--title", "Dependent", "--deps", &dep, "--json"]);
// Archive the dependency.
env.run(&["archive", &dep]);
// The dependent should now be ready.
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(),
1,
"dependent should be ready after dep is archived"
);
assert_eq!(arr[0]["title"], "Dependent");
}
/// A closed dependency unblocks dependent tickets (closed counts as resolved).
@ -1071,8 +1134,8 @@ fn closed_dep_unblocks_dependent() {
let dep = env.create(&["--title", "Dep ticket"]);
env.run(&["create", "--title", "Dependent", "--deps", &dep, "--json"]);
// Archive (close) the dependency.
env.run(&["archive", &dep]);
// Close (won't-fix) the dependency.
env.run(&["update", &dep, "--status", "closed"]);
// The dependent should now be ready.
let output = env.run(&["ready", "--json"]);
@ -1088,13 +1151,29 @@ fn closed_dep_unblocks_dependent() {
assert_eq!(arr[0]["title"], "Dependent");
}
/// `nbd ready` does not include archived tickets.
#[test]
fn ready_excludes_archived_tickets() {
let env = TestEnv::new();
let id = env.create(&["--title", "Will be archived"]);
env.run(&["archive", &id]);
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, "archived tickets should not appear in ready");
}
/// `nbd ready` does not include closed tickets.
#[test]
fn ready_excludes_closed_tickets() {
let env = TestEnv::new();
let id = env.create(&["--title", "Will be closed"]);
env.run(&["archive", &id]);
env.run(&["update", &id, "--status", "closed"]);
let output = env.run(&["ready", "--json"]);
assert!(output.status.success());

Loading…
Cancel
Save