From 05beff775264962ec20549076e60678d41753f9d Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Mon, 23 Feb 2026 13:09:08 -0800 Subject: [PATCH] =?UTF-8?q?feat(nbd):=20split=20archive/closed=20=E2=80=94?= =?UTF-8?q?=20archive=3Darchived,=20closed=3Dcancelled=20[feb901]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- nbd/CLAUDE.md | 4 +- nbd/README.md | 26 ++++++---- nbd/src/display.rs | 1 + nbd/src/filter.rs | 1 + nbd/src/graph.rs | 1 + nbd/src/main.rs | 60 ++++++++++++--------- nbd/src/tests.rs | 8 +++ nbd/src/ticket.rs | 27 +++++++--- nbd/tests/integration.rs | 109 +++++++++++++++++++++++++++++++++------ 9 files changed, 177 insertions(+), 60 deletions(-) diff --git a/nbd/CLAUDE.md b/nbd/CLAUDE.md index f0b801a..de00b89 100644 --- a/nbd/CLAUDE.md +++ b/nbd/CLAUDE.md @@ -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 // 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 [--json] diff --git a/nbd/README.md b/nbd/README.md index f92c1b8..1bc3010 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` \| `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 --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 --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 --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 diff --git a/nbd/src/display.rs b/nbd/src/display.rs index 3dcd146..7d52fae 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::Archived => "archived", Status::Backlog => "backlog", } } diff --git a/nbd/src/filter.rs b/nbd/src/filter.rs index 88b4ebf..ac84dd2 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::Archived => "archived", Status::Backlog => "backlog", } } diff --git a/nbd/src/graph.rs b/nbd/src/graph.rs index e7caf46..d3828e1 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::Archived => "archived", Status::Backlog => "backlog", } } diff --git a/nbd/src/main.rs b/nbd/src/main.rs index be1c2a5..618e8b3 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`, `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, /// 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, }, - /// 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 --status closed`. + /// This is syntactic sugar for `nbd update --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 { "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, 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, 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, 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, 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, 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, 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, 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 --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 --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 { diff --git a/nbd/src/tests.rs b/nbd/src/tests.rs index 2c70cf3..8db8350 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::Archived).unwrap(), + "\"archived\"" + ); assert_eq!( serde_json::to_string(&Status::Backlog).unwrap(), "\"backlog\"" @@ -101,6 +105,10 @@ mod ticket { serde_json::from_str::("\"closed\"").unwrap(), Status::Closed ); + assert_eq!( + serde_json::from_str::("\"archived\"").unwrap(), + Status::Archived + ); assert_eq!( serde_json::from_str::("\"backlog\"").unwrap(), Status::Backlog diff --git a/nbd/src/ticket.rs b/nbd/src/ticket.rs index 4215ff0..e171ece 100644 --- a/nbd/src/ticket.rs +++ b/nbd/src/ticket.rs @@ -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 ` 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 --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 `. 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, diff --git a/nbd/tests/integration.rs b/nbd/tests/integration.rs index 887511c..965c43e 100644 --- a/nbd/tests/integration.rs +++ b/nbd/tests/integration.rs @@ -966,9 +966,9 @@ fn list_filter_type_and_status_wildcard_includes_done_bugs() { // ── nbd archive tests ───────────────────────────────────────────────────────── -/// `nbd archive ` sets the ticket status to `closed`. +/// `nbd archive ` 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());