From 8e2fdb57963a752c070cedd3804ee855fc2cb56f Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Sun, 22 Feb 2026 15:17:29 -0800 Subject: [PATCH] Closing tickets, adding jq --- flake.nix | 3 +++ nbd/.nbd/tickets/887344.json | 10 ++++++++++ nbd/.nbd/tickets/92e45b.json | 10 ++++++++++ nbd/.nbd/tickets/c2a024.json | 8 ++++++++ nbd/.nbd/tickets/fc6df4.json | 10 ++++++++++ nbd/CLAUDE.md | 4 ++-- 6 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 nbd/.nbd/tickets/887344.json create mode 100644 nbd/.nbd/tickets/92e45b.json create mode 100644 nbd/.nbd/tickets/c2a024.json create mode 100644 nbd/.nbd/tickets/fc6df4.json diff --git a/flake.nix b/flake.nix index e5133b3..1c3c927 100644 --- a/flake.nix +++ b/flake.nix @@ -46,6 +46,9 @@ # General pkgs.pkg-config pkgs.openssl + + # jq for json parsing + pkgs.jq ]; shellHook = '' diff --git a/nbd/.nbd/tickets/887344.json b/nbd/.nbd/tickets/887344.json new file mode 100644 index 0000000..5646320 --- /dev/null +++ b/nbd/.nbd/tickets/887344.json @@ -0,0 +1,10 @@ +{ + "title": "Wire --filter flag into list, ready, and migrate commands", + "body": "## Summary\n\nAdd `--filter KEY=VALUE` (repeatable) to the `list`, `ready`, and `migrate` CLI commands.\nParse filter arguments into a `TicketFilter` and apply it in each command handler.\n\nDepends on: TicketFilter module (ticket c2a024).\n\n## CLI changes (src/main.rs)\n\nAdd to `Commands::List`, `Commands::Ready`, and `Commands::Migrate` variants:\n\n```rust\n/// Filter tickets: key=value pairs (repeatable).\n/// Keys: priority, type, status, title.\n/// Different keys are ANDed; same key with multiple values is ORed.\n/// Values support glob wildcards: status=* matches all statuses.\n#[arg(long = \"filter\", value_name = \"KEY=VALUE\")]\nfilter: Vec,\n```\n\nUpdate the `dispatch` function to pass filter args through to each handler.\n\n## Handler changes\n\n### cmd_list(filter_args, json)\n\n```rust\nlet filter = filter::parse_filters(&filter_args)?;\nlet tickets: Vec = list_tickets(&root).await?\n .into_iter()\n .filter(|t| filter.matches(t))\n .collect();\n```\n\nNote: the default done-exclusion behaviour (ticket for that is separate) will also\nlive here, layered on top of this filter application.\n\n### cmd_ready(filter_args, json)\n\nApply the user filter AFTER the ready check. The ready check (not done + all deps done)\nis always applied first; the user's filter narrows further within ready tickets.\n\n```rust\nlet filter = filter::parse_filters(&filter_args)?;\n// ... build done_ids as before ...\nlet ready: Vec = all\n .into_iter()\n .filter(|t| {\n t.status \\!= Status::Done\n && t.dependencies.iter().all(|dep| done_ids.contains(dep.as_str()))\n && filter.matches(t)\n })\n .collect();\n```\n\n### cmd_migrate(filter_args, dry_run, json)\n\nFor migrate, the filter selects which tickets are candidates for migration.\nTickets not matching the filter are skipped (counted separately, not treated as errors).\n\nAdd a `skipped` field to `MigrateReport` in `store.rs`:\n\n```rust\npub struct MigrateReport {\n pub updated: usize,\n pub already_current: usize,\n pub skipped: usize, // NEW: tickets excluded by filter\n pub errors: Vec<(String, String)>,\n}\n```\n\nUpdate `migrate_tickets` signature in `store.rs`:\n\n```rust\npub async fn migrate_tickets(\n root: &Path,\n dry_run: bool,\n filter: &TicketFilter,\n) -> Result\n```\n\nInside the per-file loop, after deserialising the ticket, check `filter.matches(&ticket)`.\nIf false: increment `report.skipped` and continue to next file.\n\nUpdate `cmd_migrate` to parse the filter and pass it to `migrate_tickets`.\n\n## display.rs changes\n\nUpdate `format_migrate_report` and `format_migrate_report_json` to include the\n`skipped` count:\n\nHuman format:\n```\nMigrated 3 tickets.\nCurrent 5 tickets (already up to date).\nSkipped 2 tickets (did not match filter).\nErrors 1 ticket could not be migrated:\n bad_ticket.json: trailing comma at line 4\n```\n\nOnly print the \"Skipped\" line when `skipped > 0`.\n\nJSON format: add `\"skipped\": N` key to the existing object.\n\n## files touched\n\n- `src/main.rs` — `filter` fields on List/Ready/Migrate variants, updated dispatch,\n updated cmd_list/cmd_ready/cmd_migrate handlers\n- `src/store.rs` — `MigrateReport::skipped`, `migrate_tickets` gains `filter` param\n- `src/display.rs` — updated `format_migrate_report` and `format_migrate_report_json`\n- `src/tests.rs` — unit tests for updated migrate report formatting\n- `tests/integration.rs` — integration tests\n\n## Integration tests to add (tests/integration.rs)\n\n**list filtering:**\n- Create tickets: 1 bug/todo, 1 task/in_progress, 1 bug/done.\n `nbd list --filter type=bug` shows only the bug tickets (done-exclusion is separate,\n but this test can use non-done bugs).\n- `nbd list --filter status=in_progress` shows only in_progress tickets.\n- `nbd list --filter status=todo --filter status=in_progress` shows both todo and in_progress\n (OR within same key).\n- `nbd list --filter type=bug --filter status=todo` shows only bug+todo tickets\n (AND across keys).\n- `nbd list --filter title=*login*` shows only tickets whose title contains \"login\".\n- `nbd list --filter status=*` matches all statuses (wildcard).\n- `nbd list --filter type=unknown` exits non-zero with an error (unknown key passes through\n as a value, but \"unknown\" does not match any type → empty results, or error? Error on\n unknown key is preferable).\n- `nbd list --filter badformat` (no `=`) exits non-zero with an error.\n\n**ready filtering:**\n- `nbd ready --filter type=bug` returns only ready bug tickets.\n- `nbd ready --filter priority=8` returns only ready tickets with priority 8.\n\n**migrate filtering:**\n- Create two tickets. Run `nbd migrate --filter status=todo --dry-run`.\n Verify `skipped` count in JSON output matches tickets not matching the filter.\n- `nbd migrate --filter status=todo --json` includes `skipped` key.\n\n**error cases:**\n- `--filter` with unknown key exits non-zero.\n- `--filter` with no `=` exits non-zero.", + "priority": 8, + "status": "done", + "dependencies": [ + "c2a024" + ], + "ticket_type": "feature" +} \ No newline at end of file diff --git a/nbd/.nbd/tickets/92e45b.json b/nbd/.nbd/tickets/92e45b.json new file mode 100644 index 0000000..6ec09db --- /dev/null +++ b/nbd/.nbd/tickets/92e45b.json @@ -0,0 +1,10 @@ +{ + "title": "Exclude done tickets from nbd list by default", + "body": "## Summary\n\nChange `nbd list` to hide `done` tickets by default. Users must explicitly opt in\nvia `--filter status=done` or `--filter status=*` to see completed tickets.\n\nThis is a **breaking change in default behavior**.\n\nDepends on: `--filter` wired into `list` (ticket 887344).\n\n## Current behaviour\n\n`nbd list` shows ALL tickets regardless of status.\n\n## New behaviour\n\n| Command | Shows |\n|---|---|\n| `nbd list` | todo + in_progress only (done excluded) |\n| `nbd list --filter status=done` | done only |\n| `nbd list --filter status=*` | all tickets (all statuses) |\n| `nbd list --filter status=todo --filter status=in_progress` | todo and in_progress |\n| `nbd list --filter type=bug` | non-done bug tickets (done still excluded) |\n| `nbd list --filter type=bug --filter status=*` | all bug tickets including done |\n\nThe key rule: **if the user provides no `status` filter key, done tickets are excluded**.\nIf the user provides any `--filter status=...` argument, the explicit status filter\nis used as-is with no implicit exclusion.\n\n## Implementation (src/main.rs)\n\nIn `cmd_list`, after parsing the filter and loading tickets, apply the logic:\n\n```rust\nlet filter = filter::parse_filters(&filter_args)?;\nlet tickets = list_tickets(&root).await?;\n\nlet tickets: Vec = tickets\n .into_iter()\n .filter(|t| {\n // If no status filter was provided by the user, exclude done tickets.\n let status_ok = if filter.has_status_filter() {\n // User expressed intent about status: use their filter.\n filter.matches_status(t)\n } else {\n // Default: hide done tickets.\n t.status \\!= Status::Done\n };\n\n // Apply remaining filter keys (type, priority, title) regardless.\n status_ok && filter.matches_except_status(t)\n })\n .collect();\n```\n\nThis requires two additional methods on `TicketFilter` (add to `src/filter.rs`):\n\n```rust\n/// Returns true if the ticket's status matches any of the status patterns.\n/// Caller is responsible for only calling this when `has_status_filter()` is true.\npub fn matches_status(&self, ticket: &Ticket) -> bool;\n\n/// Returns true if the ticket matches all non-status filter groups (type, priority, title).\n/// The status group is intentionally excluded so callers can handle it separately.\npub fn matches_except_status(&self, ticket: &Ticket) -> bool;\n```\n\n## CLI help text update\n\nUpdate the `List` variant doc comment:\n\n```rust\n/// List tickets sorted by priority (highest first).\n///\n/// By default, tickets with status `done` are excluded. Use\n/// `--filter status=*` to include all tickets, or\n/// `--filter status=done` to show only completed tickets.\nList {\n #[arg(long = \"filter\", value_name = \"KEY=VALUE\")]\n filter: Vec,\n},\n```\n\n## Existing tests that need updating\n\nThe integration test `list_shows_created_tickets` creates two tickets with default\nstatus (`todo`) and asserts both appear in `nbd list`. This test is unaffected because\nthe default tickets are not done. However, any future test that creates a ticket and\nimmediately lists without marking it done will still work.\n\nCheck: is there any existing test that creates a done ticket and expects it in `nbd list`?\nIf so, update that test to use `--filter status=done` or `--filter status=*`.\n\n## New integration tests to add (tests/integration.rs)\n\n- Create two tickets: one todo, one done. `nbd list` shows only the todo one.\n- Create two tickets: one todo, one done. `nbd list --filter status=done` shows only the done one.\n- Create two tickets: one todo, one done. `nbd list --filter status=*` shows both.\n- Create 3 tickets: bug/todo, bug/done, task/todo.\n `nbd list --filter type=bug` shows only bug/todo (done excluded by default).\n `nbd list --filter type=bug --filter status=*` shows both bug tickets.\n- `nbd list --json` does not include done tickets (verify JSON array length).\n- `nbd list --filter status=* --json` includes done tickets.\n\n## README update\n\nUpdate the \"List all tickets\" section in README.md:\n\n```\n### List all tickets\n\n```sh\nnbd list # excludes done tickets\nnbd list --filter status=* # all tickets including done\nnbd list --filter status=done # only completed tickets\nnbd list --filter type=bug # non-done bug tickets\nnbd list --json\n```\n```\n\n## Files touched\n\n- `src/main.rs` — `cmd_list` implementation and `List` help text\n- `src/filter.rs` — `matches_status`, `matches_except_status` methods\n- `src/tests.rs` — unit tests for new filter methods\n- `tests/integration.rs` — new tests, update any affected existing tests\n- `README.md` — updated usage section", + "priority": 7, + "status": "done", + "dependencies": [ + "887344" + ], + "ticket_type": "feature" +} \ No newline at end of file diff --git a/nbd/.nbd/tickets/c2a024.json b/nbd/.nbd/tickets/c2a024.json new file mode 100644 index 0000000..98a1d39 --- /dev/null +++ b/nbd/.nbd/tickets/c2a024.json @@ -0,0 +1,8 @@ +{ + "title": "Implement TicketFilter module with glob matching", + "body": "## Summary\n\nAdd a `src/filter.rs` module implementing `TicketFilter`, which parses and applies\n`key=value` filter expressions against a list of tickets. This is the foundational\nbuilding block for the `--filter` flag on `list`, `ready`, `migrate`, and `next` commands.\n\nNo external crate is needed — glob matching is implemented with a simple hand-rolled\nalgorithm that handles `*` wildcards.\n\n## Filter semantics\n\n- `--filter key=value` can be specified multiple times.\n- Keys: `priority`, `type`, `status`, `title`.\n- **Different keys are ANDed**: `--filter type=bug --filter status=todo` matches tickets\n that are BOTH bugs AND todo.\n- **Same key, multiple values are ORed**: `--filter status=todo --filter status=in_progress`\n matches tickets with EITHER status.\n- Values support `*` as a wildcard anywhere in the pattern:\n - `status=*` matches any status\n - `title=*command*` matches any title containing \"command\"\n - `priority=7` matches exactly priority 7 (as string comparison)\n\n## Data structures\n\n```rust\n/// A compiled set of filter expressions applied to tickets.\n///\n/// Within the same key, patterns are ORed (any match passes the group).\n/// Across different keys, groups are ANDed (all non-empty groups must pass).\npub struct TicketFilter {\n /// Glob patterns for `status` (OR within this group).\n pub status: Vec,\n /// Glob patterns for `ticket_type` (OR within this group).\n pub ticket_type: Vec,\n /// Glob patterns for `priority` (OR within this group; matched against string repr).\n pub priority: Vec,\n /// Glob patterns for `title` (OR within this group).\n pub title: Vec,\n}\n```\n\nAn empty `Vec` for a key means \"no filter on that key\" — matches everything.\n\n## API\n\n```rust\n/// Parse a slice of \"key=value\" strings into a TicketFilter.\n///\n/// The key `type` maps to the `ticket_type` field.\n/// Returns an error for unknown keys or malformed expressions (no '=').\npub fn parse_filters(args: &[String]) -> crate::store::Result\n\nimpl TicketFilter {\n /// Returns true when all non-empty filter groups match this ticket.\n /// An empty TicketFilter always returns true.\n pub fn matches(&self, ticket: &crate::ticket::Ticket) -> bool;\n\n /// Returns true when no filter groups are set (no-op filter).\n pub fn is_empty(&self) -> bool;\n\n /// Returns true when the user has provided at least one status pattern.\n /// Used by cmd_list to detect whether to apply the implicit done-exclusion.\n pub fn has_status_filter(&self) -> bool;\n}\n```\n\n## Glob algorithm (no external crate)\n\nImplement a private `fn glob_matches(pattern: &str, value: &str) -> bool`:\n\n1. If pattern contains no `*`, require exact equality.\n2. Split pattern on `*` into segments.\n3. If pattern does NOT start with `*`, value must start with `segments[0]`.\n4. If pattern does NOT end with `*`, value must end with `segments[last]`.\n5. For each remaining segment, find it in the remaining suffix of value (left to right).\n Advance past the match and continue. If any segment is not found, return false.\n\nThis handles: `*`, `foo`, `foo*`, `*foo`, `*foo*`, `foo*bar`, `*foo*bar*`.\n\nCase sensitivity:\n- `status` and `type` patterns: case-insensitive (compare lowercase).\n- `title` and `priority` patterns: case-sensitive.\n\n## matches() logic\n\n```\nfn matches(&self, ticket) -> bool {\n (self.status.is_empty() || self.status.iter().any(|p| glob_matches(p, status_str(&ticket.status))))\n && (self.ticket_type.is_empty() || self.ticket_type.iter().any(|p| glob_matches(p, ticket_type_str(&ticket.ticket_type))))\n && (self.priority.is_empty() || self.priority.iter().any(|p| glob_matches(p, &ticket.priority.to_string())))\n && (self.title.is_empty() || self.title.iter().any(|p| glob_matches(p, &ticket.title)))\n}\n```\n\nUse `status_str` and `ticket_type_str` equivalents (can be private functions in filter.rs\nor call into display, or duplicate the small match arms).\n\n## Module registration\n\nAdd `mod filter;` to `src/main.rs` (or move to `lib.rs` if the project ever gains one).\nThe module is `pub(crate)`.\n\n## Files touched\n\n- `src/filter.rs` — new module\n- `src/main.rs` — add `mod filter;`\n- `src/tests.rs` — unit tests\n\n## Unit tests to write (src/tests.rs)\n\n- `parse_filters` rejects unknown keys with a descriptive error.\n- `parse_filters` rejects a string with no `=`.\n- `parse_filters` with `key=value=more` treats everything after first `=` as the value.\n- `glob_matches(\"*\", \"anything\")` → true.\n- `glob_matches(\"*\", \"\")` → true.\n- `glob_matches(\"todo\", \"todo\")` → true; `glob_matches(\"todo\", \"done\")` → false.\n- `glob_matches(\"*command*\", \"add command here\")` → true.\n- `glob_matches(\"*command*\", \"no match\")` → false.\n- `glob_matches(\"in_*\", \"in_progress\")` → true.\n- `glob_matches(\"in_*\", \"todo\")` → false.\n- `TicketFilter::matches` — two different keys AND correctly (both must match).\n- `TicketFilter::matches` — same key OR correctly (either matches).\n- `TicketFilter::matches` — empty filter matches everything.\n- `TicketFilter::is_empty` — true when no filters, false when any filter set.\n- `TicketFilter::has_status_filter` — true iff status vec is non-empty.", + "priority": 8, + "status": "done", + "dependencies": [], + "ticket_type": "feature" +} \ No newline at end of file diff --git a/nbd/.nbd/tickets/fc6df4.json b/nbd/.nbd/tickets/fc6df4.json new file mode 100644 index 0000000..5c61497 --- /dev/null +++ b/nbd/.nbd/tickets/fc6df4.json @@ -0,0 +1,10 @@ +{ + "title": "Add nbd next subcommand", + "body": "## Summary\n\nAdd `nbd next` subcommand that selects the single highest-priority ticket that is\nready to work on. Supports `--filter` for additional narrowing within the ready set.\n\nDepends on: `--filter` wired into CLI (ticket 887344). Implicitly depends on the\nTicketFilter module (ticket c2a024) through that.\n\n## Definition of \"ready\"\n\nSame semantics as `nbd ready`:\n- `status \\!= done`\n- Every ID in `ticket.dependencies` belongs to a ticket with `status == done`.\n- Missing dependency IDs are treated conservatively: the ticket is NOT ready.\n\n## Behaviour\n\n1. Load all tickets with `list_tickets(&root)` (already sorted by priority desc).\n2. Build `done_ids` set (same as `cmd_ready`).\n3. Find the first ticket (highest priority) where:\n - status \\!= done\n - all dependencies are in done_ids\n - `filter.matches(t)` (if a filter was provided)\n4. If found: display the single ticket.\n5. If not found: print a \"no ready tickets\" message. Exit 0 (not an error).\n\n## Output\n\nWithout `--json`:\n- Found: print the ticket in the same tabular format as `nbd read` (via `display::print_ticket`).\n- Not found: `No ready tickets.`\n\nWith `--json`:\n- Found: `{\"next\": { ...ticket fields including id... }}`\n- Not found: `{\"next\": null}`\n\nWrapping in `{\"next\": ...}` (rather than bare ticket or bare null) makes the JSON\nunambiguously parseable by consumers — they always get an object with a `next` key.\n\n## Implementation\n\nAdd to `Commands` enum:\n\n```rust\n/// Choose the highest-priority ticket that is ready to work on.\n///\n/// A ticket is ready when its status is not `done` and every ticket it\n/// depends on has status `done`. Returns the single highest-priority\n/// ready ticket, optionally narrowed by `--filter KEY=VALUE`.\n///\n/// Exits 0 even when no ready ticket exists.\nNext {\n /// Filter ready tickets: key=value pairs (repeatable).\n /// AND between different keys, OR within same key.\n #[arg(long = \"filter\", value_name = \"KEY=VALUE\")]\n filter: Vec,\n},\n```\n\nAdd dispatch arm:\n\n```rust\nCommands::Next { filter } => cmd_next(filter, cli.json).await,\n```\n\nImplement `cmd_next`:\n\n```rust\nasync fn cmd_next(filter_args: Vec, json: bool) -> store::Result<()> {\n let root = find_nbd_root()?;\n let all = list_tickets(&root).await?; // sorted by priority desc\n let filter = filter::parse_filters(&filter_args)?;\n\n let done_ids: std::collections::HashSet<&str> = all\n .iter()\n .filter(|t| t.status == Status::Done)\n .map(|t| t.id.as_str())\n .collect();\n\n let next = all.iter().find(|t| {\n t.status \\!= Status::Done\n && t.dependencies.iter().all(|dep| done_ids.contains(dep.as_str()))\n && filter.matches(t)\n });\n\n if json {\n match next {\n Some(ticket) => {\n let value = serde_json::json\\!({\n \"next\": display::ticket_to_json_value(ticket)\n });\n println\\!(\"{}\", serde_json::to_string_pretty(&value)?);\n }\n None => println\\!(\"{}\", serde_json::json\\!({\"next\": null})),\n }\n } else {\n match next {\n Some(ticket) => display::print_ticket(ticket),\n None => println\\!(\"No ready tickets.\"),\n }\n }\n\n Ok(())\n}\n```\n\n## display.rs: make ticket_to_json_value pub(crate)\n\n`ticket_to_json_value` is currently private in `display.rs`. It needs to be accessible\nfrom `cmd_next` in `main.rs`. Change its visibility:\n\n```rust\npub(crate) fn ticket_to_json_value(ticket: &Ticket) -> serde_json::Value { ... }\n```\n\nThis is the cleanest approach — it reuses the existing id-injection logic rather than\nduplicating it.\n\n## README update\n\nAdd a `### Find the next ticket to work on` section:\n\n```markdown\n### Find the next ticket to work on\n\nReturns the single highest-priority ticket that is ready to work on — not done\nand with all dependencies completed.\n\n```sh\nnbd next\nnbd next --json\nnbd next --filter type=bug # highest-priority ready bug\nnbd next --filter priority=9 # highest-priority ready ticket with priority 9\n```\n\nExits 0 even when no ready ticket exists.\n```\n\n## CLAUDE.md update\n\nUpdate the \"Workflow\" section to mention `nbd next` as an alternative to `nbd ready`\nwhen the caller just wants to begin the single most important task:\n\n```\n**To get the single best ticket to work on next:**\n\n```sh\ncargo run -- next --json\n```\n```\n\n## Files touched\n\n- `src/main.rs` — `Next` command variant, `cmd_next` handler, dispatch arm\n- `src/display.rs` — `ticket_to_json_value` changed to `pub(crate)`\n- `tests/integration.rs` — integration tests\n- `README.md` — new section for `nbd next`\n- `CLAUDE.md` — update workflow section\n\n## Integration tests to add (tests/integration.rs)\n\n- Three tickets: A (priority 5, no deps), B (priority 8, dep A), C (priority 7, no deps).\n `nbd next --json` returns C (highest priority ready ticket — B is blocked by A).\n- After marking A done: `nbd next --json` returns B (priority 8, now unblocked).\n- With only done tickets: `nbd next --json` returns `{\"next\": null}`.\n- `nbd next` (no `--json`) with no ready tickets prints \"No ready tickets.\" and exits 0.\n- `nbd next --filter type=bug --json`: create a bug and a task, both ready.\n Returns the bug if it's the highest-priority bug, otherwise the highest-priority bug.\n (Create bug priority 8, task priority 9: with filter, should return the bug.)\n- `nbd next --json` returns a JSON object with a `\"next\"` key containing all ticket fields\n including `\"id\"`.\n- `nbd next --filter priority=99 --json` returns `{\"next\": null}` (no ticket has priority 99).", + "priority": 7, + "status": "todo", + "dependencies": [ + "887344" + ], + "ticket_type": "feature" +} \ No newline at end of file diff --git a/nbd/CLAUDE.md b/nbd/CLAUDE.md index 00c525e..01f017b 100644 --- a/nbd/CLAUDE.md +++ b/nbd/CLAUDE.md @@ -96,8 +96,8 @@ cargo run -- init ### Workflow -Always pass `--json` to every command. The tabular output is for humans; JSON -is unambiguous and easy to parse reliably. +Always pass `--json` to every command. +Use `jq` to parse and transform the JSON output if necessary. **Before starting work:** Create a ticket for the task.