Migrate all tickets to `.md` format
parent
0bd7bd8c0f
commit
9a58e78ca8
@ -1,10 +0,0 @@
|
||||
{
|
||||
"title": "nbd migrate command",
|
||||
"body": "Add `nbd migrate` to bring all ticket files on disk into conformance with the current schema. This is the standard mechanism for handling any schema change — field removals, field additions, renames, or type changes.\n\n## Motivation\n\nSchema changes (like removing `id` from the JSON body, adding new fields with defaults, or renaming a field) leave existing ticket files in an old format. `nbd migrate` re-serialises every ticket through the current serde schema, which:\n- **Removes** fields that no longer exist in `Ticket` (they are ignored on deserialise, then absent on re-serialise).\n- **Adds** new fields with their `#[serde(default)]` values.\n- **Normalises** any formatting differences (e.g. key order, whitespace).\n\nThe current immediate use case is scrubbing the `\"id\"` key from all existing `.json` files after the id-from-filename schema change.\n\n## Design principles\n\n- **Idempotent.** Running `nbd migrate` on an already-current store is a no-op (files are re-written identically).\n- **Non-destructive.** A failure on one ticket does not abort the rest; errors are collected and reported at the end.\n- **Source of truth unchanged.** If a ticket cannot be parsed, it is left on disk as-is and reported as an error.\n- **Dry-run available.** `--dry-run` prints what would change without writing.\n\n## Approach\n\n### main.rs\n\nAdd `Migrate` variant to `Commands`:\n```\nMigrate {\n /// Print changes without writing them.\n #[arg(long)]\n dry_run: bool,\n}\n```\n\nImplement `cmd_migrate(dry_run: bool) -> store::Result<()>`:\n1. `find_nbd_root()`\n2. Call `store::migrate_tickets(&root, dry_run).await`\n3. Print a summary: `Migrated N tickets (M errors)`.\n\n### store.rs\n\nAdd `migrate_tickets(root: &Path, dry_run: bool) -> Result<MigrateReport>`:\n1. `fs::read_dir(tickets_dir(root))`\n2. For each `*.json`, `*.md`, `*.toml`, `*.jsonb` file (all supported formats — the ones that exist now and future ones added by the multi-format feature):\n a. Read the raw bytes.\n b. Attempt to deserialise into current `Ticket`, injecting `id` from filename.\n c. Re-serialise to the current schema (same format as the original file's extension).\n d. Compare raw bytes. If unchanged, skip (count as already-current).\n e. If changed and `dry_run`: print `would update {filename}`, do not write.\n f. If changed and not `dry_run`: write the new bytes to the same path.\n g. If deserialise fails: record the error, leave the file untouched.\n3. Return `MigrateReport { updated: usize, already_current: usize, errors: Vec<(String, String)> }`.\n\n```rust\npub struct MigrateReport {\n pub updated: usize,\n pub already_current: usize,\n pub errors: Vec<(String, String)>, // (filename, error message)\n}\n```\n\n### display.rs\n\nAdd `print_migrate_report(report: &MigrateReport)`:\n```\nMigrated 3 tickets.\nCurrent 5 tickets (already up to date).\nErrors 1 ticket could not be migrated:\n bad_ticket.json: trailing comma at line 4\n```\n\nWhen `--json`, serialise `MigrateReport` directly (derive `Serialize`).\n\n## How schema changes use this\n\nFor **field removal** (e.g. removing `id` from JSON):\n- Old files have `\"id\": \"...\"` → on deserialise, serde ignores it (unknown field).\n- Re-serialise → `id` is absent (since `#[serde(skip)]`).\n- File bytes differ → `migrate` rewrites.\n\nFor **field addition** (e.g. adding `tags: Vec<String>` later):\n- New field in `Ticket` gets `#[serde(default)]`.\n- Old files lack `tags` → deserialise gives `vec![]`.\n- Re-serialise → `\"tags\": []` is written.\n- File bytes differ → `migrate` rewrites.\n\n## Tests\n\nUnit tests (`src/tests.rs`):\n- `migrate_tickets` on a store with old-format files (containing `\"id\"`) rewrites them without `id`.\n- `migrate_tickets` on an already-current store returns `updated: 0`, `already_current: N`.\n- `migrate_tickets --dry-run` does not modify files on disk.\n- A file with invalid JSON is counted in `errors` and left unchanged.\n\nIntegration tests (`tests/integration.rs`):\n- Create tickets with old code (inject `id` manually into JSON), run `nbd migrate`, verify `id` is gone from files.\n- `nbd migrate --dry-run` reports changes but does not modify files.\n- `nbd migrate` exits zero even when some tickets error (but prints error summary).\n- `nbd migrate --json` outputs a valid JSON object with `updated`, `already_current`, `errors` fields.\n\n## Files touched\n- `src/main.rs` — `Migrate` command, `cmd_migrate`\n- `src/store.rs` — `migrate_tickets`, `MigrateReport`\n- `src/display.rs` — `print_migrate_report`\n- `src/tests.rs` — unit tests\n- `tests/integration.rs` — integration tests\n- `README.md` — document `nbd migrate`",
|
||||
"priority": 9,
|
||||
"status": "done",
|
||||
"dependencies": [
|
||||
"d1634a"
|
||||
],
|
||||
"ticket_type": "feature"
|
||||
}
|
||||
@ -0,0 +1,111 @@
|
||||
+++
|
||||
title = "nbd migrate command"
|
||||
priority = 9
|
||||
status = "done"
|
||||
ticket_type = "feature"
|
||||
dependencies = ["d1634a"]
|
||||
+++
|
||||
Add `nbd migrate` to bring all ticket files on disk into conformance with the current schema. This is the standard mechanism for handling any schema change — field removals, field additions, renames, or type changes.
|
||||
|
||||
## Motivation
|
||||
|
||||
Schema changes (like removing `id` from the JSON body, adding new fields with defaults, or renaming a field) leave existing ticket files in an old format. `nbd migrate` re-serialises every ticket through the current serde schema, which:
|
||||
- **Removes** fields that no longer exist in `Ticket` (they are ignored on deserialise, then absent on re-serialise).
|
||||
- **Adds** new fields with their `#[serde(default)]` values.
|
||||
- **Normalises** any formatting differences (e.g. key order, whitespace).
|
||||
|
||||
The current immediate use case is scrubbing the `"id"` key from all existing `.json` files after the id-from-filename schema change.
|
||||
|
||||
## Design principles
|
||||
|
||||
- **Idempotent.** Running `nbd migrate` on an already-current store is a no-op (files are re-written identically).
|
||||
- **Non-destructive.** A failure on one ticket does not abort the rest; errors are collected and reported at the end.
|
||||
- **Source of truth unchanged.** If a ticket cannot be parsed, it is left on disk as-is and reported as an error.
|
||||
- **Dry-run available.** `--dry-run` prints what would change without writing.
|
||||
|
||||
## Approach
|
||||
|
||||
### main.rs
|
||||
|
||||
Add `Migrate` variant to `Commands`:
|
||||
```
|
||||
Migrate {
|
||||
/// Print changes without writing them.
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
}
|
||||
```
|
||||
|
||||
Implement `cmd_migrate(dry_run: bool) -> store::Result<()>`:
|
||||
1. `find_nbd_root()`
|
||||
2. Call `store::migrate_tickets(&root, dry_run).await`
|
||||
3. Print a summary: `Migrated N tickets (M errors)`.
|
||||
|
||||
### store.rs
|
||||
|
||||
Add `migrate_tickets(root: &Path, dry_run: bool) -> Result<MigrateReport>`:
|
||||
1. `fs::read_dir(tickets_dir(root))`
|
||||
2. For each `*.json`, `*.md`, `*.toml`, `*.jsonb` file (all supported formats — the ones that exist now and future ones added by the multi-format feature):
|
||||
a. Read the raw bytes.
|
||||
b. Attempt to deserialise into current `Ticket`, injecting `id` from filename.
|
||||
c. Re-serialise to the current schema (same format as the original file's extension).
|
||||
d. Compare raw bytes. If unchanged, skip (count as already-current).
|
||||
e. If changed and `dry_run`: print `would update {filename}`, do not write.
|
||||
f. If changed and not `dry_run`: write the new bytes to the same path.
|
||||
g. If deserialise fails: record the error, leave the file untouched.
|
||||
3. Return `MigrateReport { updated: usize, already_current: usize, errors: Vec<(String, String)> }`.
|
||||
|
||||
```rust
|
||||
pub struct MigrateReport {
|
||||
pub updated: usize,
|
||||
pub already_current: usize,
|
||||
pub errors: Vec<(String, String)>, // (filename, error message)
|
||||
}
|
||||
```
|
||||
|
||||
### display.rs
|
||||
|
||||
Add `print_migrate_report(report: &MigrateReport)`:
|
||||
```
|
||||
Migrated 3 tickets.
|
||||
Current 5 tickets (already up to date).
|
||||
Errors 1 ticket could not be migrated:
|
||||
bad_ticket.json: trailing comma at line 4
|
||||
```
|
||||
|
||||
When `--json`, serialise `MigrateReport` directly (derive `Serialize`).
|
||||
|
||||
## How schema changes use this
|
||||
|
||||
For **field removal** (e.g. removing `id` from JSON):
|
||||
- Old files have `"id": "..."` → on deserialise, serde ignores it (unknown field).
|
||||
- Re-serialise → `id` is absent (since `#[serde(skip)]`).
|
||||
- File bytes differ → `migrate` rewrites.
|
||||
|
||||
For **field addition** (e.g. adding `tags: Vec<String>` later):
|
||||
- New field in `Ticket` gets `#[serde(default)]`.
|
||||
- Old files lack `tags` → deserialise gives `vec![]`.
|
||||
- Re-serialise → `"tags": []` is written.
|
||||
- File bytes differ → `migrate` rewrites.
|
||||
|
||||
## Tests
|
||||
|
||||
Unit tests (`src/tests.rs`):
|
||||
- `migrate_tickets` on a store with old-format files (containing `"id"`) rewrites them without `id`.
|
||||
- `migrate_tickets` on an already-current store returns `updated: 0`, `already_current: N`.
|
||||
- `migrate_tickets --dry-run` does not modify files on disk.
|
||||
- A file with invalid JSON is counted in `errors` and left unchanged.
|
||||
|
||||
Integration tests (`tests/integration.rs`):
|
||||
- Create tickets with old code (inject `id` manually into JSON), run `nbd migrate`, verify `id` is gone from files.
|
||||
- `nbd migrate --dry-run` reports changes but does not modify files.
|
||||
- `nbd migrate` exits zero even when some tickets error (but prints error summary).
|
||||
- `nbd migrate --json` outputs a valid JSON object with `updated`, `already_current`, `errors` fields.
|
||||
|
||||
## Files touched
|
||||
- `src/main.rs` — `Migrate` command, `cmd_migrate`
|
||||
- `src/store.rs` — `migrate_tickets`, `MigrateReport`
|
||||
- `src/display.rs` — `print_migrate_report`
|
||||
- `src/tests.rs` — unit tests
|
||||
- `tests/integration.rs` — integration tests
|
||||
- `README.md` — document `nbd migrate`
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"title": "nbd archive command and Closed status",
|
||||
"body": "Add `Status::Closed` (serialised as `\"closed\"`) and a convenience `nbd archive <id>` command that sets it.\n\n## Motivation\n\n`done` tickets clutter `nbd list`. `closed` provides a soft-delete: the ticket is preserved on disk but excluded from normal listings by default.\n\n## Approach\n\n### ticket.rs\n- Add `Closed` variant to `Status` enum (after `Done`).\n- `#[serde(rename_all = \"snake_case\")]` already handles serialisation → `\"closed\"`.\n\n### main.rs\n- Update `parse_status` to accept `\"closed\"`.\n- Update `status_str` in `display.rs` to map `Status::Closed` → `\"closed\"`.\n- Add `Archive` variant to `Commands`:\n ```\n Archive { id: String }\n ```\n- Implement `cmd_archive(id, json)`: read ticket → set status to `Closed` → write → print.\n This is syntactic sugar for `nbd update <id> --status closed`.\n\n### display.rs\n- Add `\"closed\"` to `status_str` match arm.\n\n### list filtering\n- `nbd list` currently shows all tickets. After this change, it should by default **hide** `Closed` tickets.\n- Add a `--all` flag to `nbd list` to show all tickets including closed ones.\n- Update `list_tickets` or filter at the command handler level. Prefer filtering in `cmd_list` to keep `list_tickets` generic.\n\n## Tests\n- Unit test: `Status::Closed` serialises to `\"closed\"` and back.\n- Integration test: `nbd archive <id>` sets status to `closed`.\n- Integration test: `nbd list` does not show closed tickets.\n- Integration test: `nbd list --all` shows closed tickets.\n\n## Files touched\n- `src/ticket.rs` — add `Closed` variant\n- `src/main.rs` — `Archive` command, `parse_status` update, `--all` flag on `list`\n- `src/display.rs` — `status_str` update\n- `src/tests.rs` — unit tests\n- `tests/integration.rs` — integration tests\n- `README.md` — document archive and --all",
|
||||
"priority": 6,
|
||||
"status": "done",
|
||||
"dependencies": [],
|
||||
"ticket_type": "feature"
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
+++
|
||||
title = "nbd archive command and Closed status"
|
||||
priority = 6
|
||||
status = "done"
|
||||
ticket_type = "feature"
|
||||
dependencies = []
|
||||
+++
|
||||
Add `Status::Closed` (serialised as `"closed"`) and a convenience `nbd archive <id>` command that sets it.
|
||||
|
||||
## Motivation
|
||||
|
||||
`done` tickets clutter `nbd list`. `closed` provides a soft-delete: the ticket is preserved on disk but excluded from normal listings by default.
|
||||
|
||||
## Approach
|
||||
|
||||
### ticket.rs
|
||||
- Add `Closed` variant to `Status` enum (after `Done`).
|
||||
- `#[serde(rename_all = "snake_case")]` already handles serialisation → `"closed"`.
|
||||
|
||||
### main.rs
|
||||
- Update `parse_status` to accept `"closed"`.
|
||||
- Update `status_str` in `display.rs` to map `Status::Closed` → `"closed"`.
|
||||
- Add `Archive` variant to `Commands`:
|
||||
```
|
||||
Archive { id: String }
|
||||
```
|
||||
- Implement `cmd_archive(id, json)`: read ticket → set status to `Closed` → write → print.
|
||||
This is syntactic sugar for `nbd update <id> --status closed`.
|
||||
|
||||
### display.rs
|
||||
- Add `"closed"` to `status_str` match arm.
|
||||
|
||||
### list filtering
|
||||
- `nbd list` currently shows all tickets. After this change, it should by default **hide** `Closed` tickets.
|
||||
- Add a `--all` flag to `nbd list` to show all tickets including closed ones.
|
||||
- Update `list_tickets` or filter at the command handler level. Prefer filtering in `cmd_list` to keep `list_tickets` generic.
|
||||
|
||||
## Tests
|
||||
- Unit test: `Status::Closed` serialises to `"closed"` and back.
|
||||
- Integration test: `nbd archive <id>` sets status to `closed`.
|
||||
- Integration test: `nbd list` does not show closed tickets.
|
||||
- Integration test: `nbd list --all` shows closed tickets.
|
||||
|
||||
## Files touched
|
||||
- `src/ticket.rs` — add `Closed` variant
|
||||
- `src/main.rs` — `Archive` command, `parse_status` update, `--all` flag on `list`
|
||||
- `src/display.rs` — `status_str` update
|
||||
- `src/tests.rs` — unit tests
|
||||
- `tests/integration.rs` — integration tests
|
||||
- `README.md` — document archive and --all
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"title": "Multiple file format support (md, toml, jsonb)",
|
||||
"body": "Add `--ftype` flag to `create` and `update` to write tickets in markdown, TOML, or binary JSON (CBOR) in addition to the existing JSON format. Format is detected from file extension on read.\n\n## Motivation\n\nMarkdown format lets agents write long-form ticket bodies with full markdown syntax, and makes tickets human-readable in a file browser. TOML is a natural config format. CBOR offers compact binary storage.\n\n## Approach\n\n### New crate dependencies (Cargo.toml)\nEvaluate and add:\n- `toml` — TOML serialisation (likely `toml = \"0.8\"`)\n- `serde_yml` or `serde_yaml` — YAML frontmatter (for `.md` files)\n- `ciborium` — CBOR binary JSON (`.jsonb`)\n\n### ticket.rs\nNo changes needed — `Ticket` already derives `Serialize`/`Deserialize`.\n\n### store.rs\nNew enum `FileFormat { Json, Markdown, Toml, Jsonb }`.\n\nNew function `detect_format(path: &Path) -> FileFormat`:\n- `.json` → `Json`\n- `.md` → `Markdown`\n- `.toml` → `Toml`\n- `.jsonb` → `Jsonb`\n- Unknown → `Json` (fallback)\n\nUpdate `ticket_path(root, id, format)` to use the format-appropriate extension. This is a breaking change to the function signature — update all callers.\n\nUpdate `read_ticket(root, id)`:\n1. Try each known extension in order until a file is found.\n2. Read the file and dispatch to the format-appropriate deserialiser.\n\nSerialisation helpers (private):\n- `serialize_json(ticket) -> String`\n- `serialize_toml(ticket) -> String`\n- `serialize_markdown(ticket) -> String` — TOML frontmatter (`+++` delimiters) with body as file content\n- `serialize_jsonb(ticket) -> Vec<u8>`\n\nDeserialization helpers (private):\n- `deserialize_markdown(bytes) -> Result<Ticket>` — parse frontmatter + body\n\nUpdate `list_tickets` to scan for `*.json`, `*.md`, `*.toml`, `*.jsonb` files.\n\nUpdate `write_ticket` to accept `format: FileFormat` and write in the appropriate format.\n\n### main.rs\nAdd `--ftype [json|md|toml|jsonb]` option (default `json`) to `create` and `update`.\nConversion on `update --ftype`: read old file, write new format, delete old file (if extension changed).\n\n## Markdown format (TOML frontmatter)\n```\n+++\nid = \"a3f9c2\"\ntitle = \"Fix login bug\"\npriority = 8\nstatus = \"in_progress\"\nticket_type = \"bug\"\ndependencies = [\"b7d41e\"]\n+++\n\nLong-form body text goes here. Supports full markdown.\n```\n\n## Tests\n- Unit tests: roundtrip each format (JSON already tested).\n- Integration tests: `nbd create --ftype md` creates a `.md` file; `nbd read` finds and parses it.\n- Integration test: `nbd update <id> --ftype toml` converts format and removes old file.\n\n## Files touched\n- `Cargo.toml` — new dependencies\n- `src/store.rs` — format detection, multi-format read/write, updated `list_tickets`\n- `src/main.rs` — `--ftype` flags\n- `src/tests.rs` — format roundtrip tests\n- `tests/integration.rs` — format integration tests\n- `README.md` — document `--ftype`\n- `docs/ARCHITECTURE.md` — update storage layout section",
|
||||
"priority": 5,
|
||||
"status": "done",
|
||||
"dependencies": [],
|
||||
"ticket_type": "feature"
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
+++
|
||||
title = "Multiple file format support (md, toml, jsonb)"
|
||||
priority = 5
|
||||
status = "done"
|
||||
ticket_type = "feature"
|
||||
dependencies = []
|
||||
+++
|
||||
Add `--ftype` flag to `create` and `update` to write tickets in markdown, TOML, or binary JSON (CBOR) in addition to the existing JSON format. Format is detected from file extension on read.
|
||||
|
||||
## Motivation
|
||||
|
||||
Markdown format lets agents write long-form ticket bodies with full markdown syntax, and makes tickets human-readable in a file browser. TOML is a natural config format. CBOR offers compact binary storage.
|
||||
|
||||
## Approach
|
||||
|
||||
### New crate dependencies (Cargo.toml)
|
||||
Evaluate and add:
|
||||
- `toml` — TOML serialisation (likely `toml = "0.8"`)
|
||||
- `serde_yml` or `serde_yaml` — YAML frontmatter (for `.md` files)
|
||||
- `ciborium` — CBOR binary JSON (`.jsonb`)
|
||||
|
||||
### ticket.rs
|
||||
No changes needed — `Ticket` already derives `Serialize`/`Deserialize`.
|
||||
|
||||
### store.rs
|
||||
New enum `FileFormat { Json, Markdown, Toml, Jsonb }`.
|
||||
|
||||
New function `detect_format(path: &Path) -> FileFormat`:
|
||||
- `.json` → `Json`
|
||||
- `.md` → `Markdown`
|
||||
- `.toml` → `Toml`
|
||||
- `.jsonb` → `Jsonb`
|
||||
- Unknown → `Json` (fallback)
|
||||
|
||||
Update `ticket_path(root, id, format)` to use the format-appropriate extension. This is a breaking change to the function signature — update all callers.
|
||||
|
||||
Update `read_ticket(root, id)`:
|
||||
1. Try each known extension in order until a file is found.
|
||||
2. Read the file and dispatch to the format-appropriate deserialiser.
|
||||
|
||||
Serialisation helpers (private):
|
||||
- `serialize_json(ticket) -> String`
|
||||
- `serialize_toml(ticket) -> String`
|
||||
- `serialize_markdown(ticket) -> String` — TOML frontmatter (`+++` delimiters) with body as file content
|
||||
- `serialize_jsonb(ticket) -> Vec<u8>`
|
||||
|
||||
Deserialization helpers (private):
|
||||
- `deserialize_markdown(bytes) -> Result<Ticket>` — parse frontmatter + body
|
||||
|
||||
Update `list_tickets` to scan for `*.json`, `*.md`, `*.toml`, `*.jsonb` files.
|
||||
|
||||
Update `write_ticket` to accept `format: FileFormat` and write in the appropriate format.
|
||||
|
||||
### main.rs
|
||||
Add `--ftype [json|md|toml|jsonb]` option (default `json`) to `create` and `update`.
|
||||
Conversion on `update --ftype`: read old file, write new format, delete old file (if extension changed).
|
||||
|
||||
## Markdown format (TOML frontmatter)
|
||||
```
|
||||
+++
|
||||
id = "a3f9c2"
|
||||
title = "Fix login bug"
|
||||
priority = 8
|
||||
status = "in_progress"
|
||||
ticket_type = "bug"
|
||||
dependencies = ["b7d41e"]
|
||||
+++
|
||||
|
||||
Long-form body text goes here. Supports full markdown.
|
||||
```
|
||||
|
||||
## Tests
|
||||
- Unit tests: roundtrip each format (JSON already tested).
|
||||
- Integration tests: `nbd create --ftype md` creates a `.md` file; `nbd read` finds and parses it.
|
||||
- Integration test: `nbd update <id> --ftype toml` converts format and removes old file.
|
||||
|
||||
## Files touched
|
||||
- `Cargo.toml` — new dependencies
|
||||
- `src/store.rs` — format detection, multi-format read/write, updated `list_tickets`
|
||||
- `src/main.rs` — `--ftype` flags
|
||||
- `src/tests.rs` — format roundtrip tests
|
||||
- `tests/integration.rs` — format integration tests
|
||||
- `README.md` — document `--ftype`
|
||||
- `docs/ARCHITECTURE.md` — update storage layout section
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"title": "nbd init command",
|
||||
"body": "Add an explicit `nbd init` subcommand that creates `.nbd/tickets/` in the current working directory, analogous to `git init`.\n\n## Motivation\n\nCurrently users must run `mkdir -p .nbd/tickets` manually before first use. This is a friction point — especially for first-time users and agent workflows bootstrapping a new project.\n\n## Approach\n\n1. Add `Init` variant to the `Commands` enum in `main.rs`.\n2. Implement `cmd_init(json: bool) -> store::Result<()>`:\n - Get cwd via `std::env::current_dir()`.\n - Call `store::ensure_tickets_dir(&cwd)` (already exists, idempotent).\n - Print confirmation: `initialised .nbd/tickets/ in <path>` (or JSON: `{\"root\": \"<path>\"}`).\n3. No changes to `store.rs` needed — `ensure_tickets_dir` already uses `create_dir_all` (idempotent).\n4. The command should NOT require `.nbd/` to already exist (i.e. do NOT call `find_nbd_root` here — use cwd directly).\n\n## Tests\n\n- Integration test: run `nbd init` in a fresh tempdir, verify `.nbd/tickets/` is created.\n- Integration test: run `nbd init` twice in the same dir — succeeds both times (idempotent).\n- Integration test: `nbd init --json` outputs valid JSON with a `root` field.\n\n## Files touched\n- `src/main.rs` — new `Init` variant and `cmd_init` handler\n- `tests/integration.rs` — new integration tests\n- `README.md` — update Initialise section",
|
||||
"priority": 7,
|
||||
"status": "done",
|
||||
"dependencies": [],
|
||||
"ticket_type": "feature"
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
+++
|
||||
title = "nbd init command"
|
||||
priority = 7
|
||||
status = "done"
|
||||
ticket_type = "feature"
|
||||
dependencies = []
|
||||
+++
|
||||
Add an explicit `nbd init` subcommand that creates `.nbd/tickets/` in the current working directory, analogous to `git init`.
|
||||
|
||||
## Motivation
|
||||
|
||||
Currently users must run `mkdir -p .nbd/tickets` manually before first use. This is a friction point — especially for first-time users and agent workflows bootstrapping a new project.
|
||||
|
||||
## Approach
|
||||
|
||||
1. Add `Init` variant to the `Commands` enum in `main.rs`.
|
||||
2. Implement `cmd_init(json: bool) -> store::Result<()>`:
|
||||
- Get cwd via `std::env::current_dir()`.
|
||||
- Call `store::ensure_tickets_dir(&cwd)` (already exists, idempotent).
|
||||
- Print confirmation: `initialised .nbd/tickets/ in <path>` (or JSON: `{"root": "<path>"}`).
|
||||
3. No changes to `store.rs` needed — `ensure_tickets_dir` already uses `create_dir_all` (idempotent).
|
||||
4. The command should NOT require `.nbd/` to already exist (i.e. do NOT call `find_nbd_root` here — use cwd directly).
|
||||
|
||||
## Tests
|
||||
|
||||
- Integration test: run `nbd init` in a fresh tempdir, verify `.nbd/tickets/` is created.
|
||||
- Integration test: run `nbd init` twice in the same dir — succeeds both times (idempotent).
|
||||
- Integration test: `nbd init --json` outputs valid JSON with a `root` field.
|
||||
|
||||
## Files touched
|
||||
- `src/main.rs` — new `Init` variant and `cmd_init` handler
|
||||
- `tests/integration.rs` — new integration tests
|
||||
- `README.md` — update Initialise section
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"title": "nbd update diff output",
|
||||
"body": "Show a git-diff-style +/- summary of what changed when `nbd update` is run without `--json`.\n\n## Motivation\n\nCurrently `nbd update` prints the full ticket after the change, making it hard to see at a glance what actually changed. A diff view — showing only changed fields — is more informative.\n\n## Approach\n\n### display.rs\nAdd `format_diff(old: &Ticket, new: &Ticket) -> String`:\n- Compare each field between `old` and `new`.\n- For each field that changed, emit two lines:\n ```\n - status: todo\n + status: in_progress\n ```\n- If no fields changed, emit `(no changes)`.\n- Fields compared: `title`, `body`, `priority`, `status`, `ticket_type`, `dependencies`.\n- `id` is never shown (it cannot change).\n- Label width matches `format_ticket` (LABEL_WIDTH).\n\nAdd `print_diff(old: &Ticket, new: &Ticket)` that calls `println!(\"{}\" format_diff(...))`.\n\n### main.rs\nIn `cmd_update`:\n- Before applying changes: `let old = ticket.clone();`\n- After `write_ticket`:\n - If `json`: current behaviour (print new ticket as JSON).\n - Else: `display::print_diff(&old, &ticket)`.\n\n## Tests\n\nUnit tests in `src/tests.rs`:\n- `format_diff` shows changed fields only.\n- `format_diff` with identical tickets outputs `(no changes)`.\n- Changed dependencies are shown as comma-separated lists on each line.\n\nIntegration test:\n- `nbd update <id> --status in_progress` (no `--json`) prints `- status:` and `+ status:` lines.\n- `nbd update <id> --json` still prints full JSON (no diff).\n\n## Files touched\n- `src/display.rs` — `format_diff`, `print_diff`\n- `src/main.rs` — `cmd_update` uses `print_diff`\n- `src/tests.rs` — unit tests for `format_diff`\n- `tests/integration.rs` — integration tests",
|
||||
"priority": 5,
|
||||
"status": "todo",
|
||||
"dependencies": [],
|
||||
"ticket_type": "feature"
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
+++
|
||||
title = "nbd update diff output"
|
||||
priority = 5
|
||||
status = "todo"
|
||||
ticket_type = "feature"
|
||||
dependencies = []
|
||||
+++
|
||||
Show a git-diff-style +/- summary of what changed when `nbd update` is run without `--json`.
|
||||
|
||||
## Motivation
|
||||
|
||||
Currently `nbd update` prints the full ticket after the change, making it hard to see at a glance what actually changed. A diff view — showing only changed fields — is more informative.
|
||||
|
||||
## Approach
|
||||
|
||||
### display.rs
|
||||
Add `format_diff(old: &Ticket, new: &Ticket) -> String`:
|
||||
- Compare each field between `old` and `new`.
|
||||
- For each field that changed, emit two lines:
|
||||
```
|
||||
- status: todo
|
||||
+ status: in_progress
|
||||
```
|
||||
- If no fields changed, emit `(no changes)`.
|
||||
- Fields compared: `title`, `body`, `priority`, `status`, `ticket_type`, `dependencies`.
|
||||
- `id` is never shown (it cannot change).
|
||||
- Label width matches `format_ticket` (LABEL_WIDTH).
|
||||
|
||||
Add `print_diff(old: &Ticket, new: &Ticket)` that calls `println!("{}" format_diff(...))`.
|
||||
|
||||
### main.rs
|
||||
In `cmd_update`:
|
||||
- Before applying changes: `let old = ticket.clone();`
|
||||
- After `write_ticket`:
|
||||
- If `json`: current behaviour (print new ticket as JSON).
|
||||
- Else: `display::print_diff(&old, &ticket)`.
|
||||
|
||||
## Tests
|
||||
|
||||
Unit tests in `src/tests.rs`:
|
||||
- `format_diff` shows changed fields only.
|
||||
- `format_diff` with identical tickets outputs `(no changes)`.
|
||||
- Changed dependencies are shown as comma-separated lists on each line.
|
||||
|
||||
Integration test:
|
||||
- `nbd update <id> --status in_progress` (no `--json`) prints `- status:` and `+ status:` lines.
|
||||
- `nbd update <id> --json` still prints full JSON (no diff).
|
||||
|
||||
## Files touched
|
||||
- `src/display.rs` — `format_diff`, `print_diff`
|
||||
- `src/main.rs` — `cmd_update` uses `print_diff`
|
||||
- `src/tests.rs` — unit tests for `format_diff`
|
||||
- `tests/integration.rs` — integration tests
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"title": "Nix flake for nbd",
|
||||
"body": "Add `nbd/flake.nix` so that `nbd` can be consumed as a Nix package and used as a CLI tool in other projects in the mono-repo.\n\n## Motivation\n\nOther `vibed` services and external projects should be able to include `nbd` as a dev dependency via Nix, getting a pinned, reproducible binary without needing Cargo installed.\n\n## Approach\n\n### nbd/flake.nix\n\nFollow the pattern from the repo root `flake.nix`. The service flake should:\n1. Inherit the base flake's inputs (nixpkgs, rust-overlay, etc.) or declare its own.\n2. Define a `packages.default` attribute that builds the `nbd` crate with `rustPlatform.buildRustPackage`.\n3. Define a `devShells.default` that includes the `nbd` binary and standard Rust tooling (rustfmt, clippy, cargo).\n4. Optionally expose an `apps.default` for `nix run .#nbd`.\n\n### Cargo.lock\nEnsure `Cargo.lock` is committed (it already is) — required for reproducible Nix builds.\n\n### cargoHash / cargoSha256\nThe `buildRustPackage` derivation requires a `cargoHash` (or `cargoSha256`). Use the correct fetcher approach (vendored deps or `fetchCargoTarball`).\n\n## Steps\n1. Read the root `flake.nix` to understand the base structure.\n2. Write `nbd/flake.nix` following the same conventions.\n3. Run `nix build .#nbd` from the `nbd/` directory to verify it builds.\n4. Run `nix run .#nbd -- --help` to verify the binary works.\n\n## Files touched\n- `nbd/flake.nix` — new file\n- `README.md` — add `nix run` usage section",
|
||||
"priority": 4,
|
||||
"status": "todo",
|
||||
"dependencies": [],
|
||||
"ticket_type": "task"
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
+++
|
||||
title = "Nix flake for nbd"
|
||||
priority = 4
|
||||
status = "todo"
|
||||
ticket_type = "task"
|
||||
dependencies = []
|
||||
+++
|
||||
Add `nbd/flake.nix` so that `nbd` can be consumed as a Nix package and used as a CLI tool in other projects in the mono-repo.
|
||||
|
||||
## Motivation
|
||||
|
||||
Other `vibed` services and external projects should be able to include `nbd` as a dev dependency via Nix, getting a pinned, reproducible binary without needing Cargo installed.
|
||||
|
||||
## Approach
|
||||
|
||||
### nbd/flake.nix
|
||||
|
||||
Follow the pattern from the repo root `flake.nix`. The service flake should:
|
||||
1. Inherit the base flake's inputs (nixpkgs, rust-overlay, etc.) or declare its own.
|
||||
2. Define a `packages.default` attribute that builds the `nbd` crate with `rustPlatform.buildRustPackage`.
|
||||
3. Define a `devShells.default` that includes the `nbd` binary and standard Rust tooling (rustfmt, clippy, cargo).
|
||||
4. Optionally expose an `apps.default` for `nix run .#nbd`.
|
||||
|
||||
### Cargo.lock
|
||||
Ensure `Cargo.lock` is committed (it already is) — required for reproducible Nix builds.
|
||||
|
||||
### cargoHash / cargoSha256
|
||||
The `buildRustPackage` derivation requires a `cargoHash` (or `cargoSha256`). Use the correct fetcher approach (vendored deps or `fetchCargoTarball`).
|
||||
|
||||
## Steps
|
||||
1. Read the root `flake.nix` to understand the base structure.
|
||||
2. Write `nbd/flake.nix` following the same conventions.
|
||||
3. Run `nix build .#nbd` from the `nbd/` directory to verify it builds.
|
||||
4. Run `nix run .#nbd -- --help` to verify the binary works.
|
||||
|
||||
## Files touched
|
||||
- `nbd/flake.nix` — new file
|
||||
- `README.md` — add `nix run` usage section
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"title": "SQLite cache for list performance",
|
||||
"body": "Add an optional SQLite cache in `.nbd/cache.db` to accelerate `nbd list` and `nbd ready` for large ticket stores.\n\n## Motivation\n\n`list_tickets` currently does O(n) file reads on every call. For stores with hundreds of tickets this is measurably slow. A SQLite cache avoids re-reading unchanged files by comparing file modification times (mtimes).\n\n## Approach\n\n### Crate dependency\nAdd `sqlx` with the `sqlite` and `runtime-async-std` features to `Cargo.toml`.\n\n### store.rs additions\nNew async function `open_cache(root: &Path) -> Result<sqlx::SqlitePool>`:\n- Opens (or creates) `.nbd/cache.db`.\n- Runs a migration: `CREATE TABLE IF NOT EXISTS tickets (id TEXT PRIMARY KEY, json TEXT NOT NULL, mtime INTEGER NOT NULL)`.\n\nNew function `list_tickets_cached(root: &Path) -> Result<Vec<Ticket>>`:\n1. Open cache.\n2. Read directory listing to get file names and mtimes.\n3. For each file: if the DB has a row with matching mtime, use cached JSON; otherwise read file, parse, insert/update row.\n4. Delete DB rows for IDs no longer on disk.\n5. Return deserialized tickets sorted by priority desc.\n\nKeep existing `list_tickets` as the non-cached fallback. Consider making `cmd_list` and `cmd_ready` use `list_tickets_cached` when available.\n\n### Migration strategy\n- The cache is always optional. If `cache.db` can't be opened, fall back to `list_tickets` (log a warning to stderr).\n- The cache is never the source of truth — the JSON files are. The cache is always reconstructable by deleting `.nbd/cache.db`.\n\n## Decision point\nDecide whether to enable the cache unconditionally or gate it behind a flag (`--cache` / `NBD_CACHE=1`). Recommendation: enable by default once the feature is stable.\n\n## Tests\n- Unit test: cache hit returns same data as direct file read.\n- Unit test: cache miss (mtime changed) re-reads the file.\n- Unit test: deleted ticket is evicted from cache.\n- Performance test (optional): benchmark 1000-ticket list with and without cache.\n\n## Files touched\n- `Cargo.toml` — add `sqlx`\n- `src/store.rs` — `open_cache`, `list_tickets_cached`\n- `src/tests.rs` — cache unit tests\n- `docs/ARCHITECTURE.md` — document the cache layer",
|
||||
"priority": 3,
|
||||
"status": "todo",
|
||||
"dependencies": [],
|
||||
"ticket_type": "feature"
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
+++
|
||||
title = "SQLite cache for list performance"
|
||||
priority = 3
|
||||
status = "todo"
|
||||
ticket_type = "feature"
|
||||
dependencies = []
|
||||
+++
|
||||
Add an optional SQLite cache in `.nbd/cache.db` to accelerate `nbd list` and `nbd ready` for large ticket stores.
|
||||
|
||||
## Motivation
|
||||
|
||||
`list_tickets` currently does O(n) file reads on every call. For stores with hundreds of tickets this is measurably slow. A SQLite cache avoids re-reading unchanged files by comparing file modification times (mtimes).
|
||||
|
||||
## Approach
|
||||
|
||||
### Crate dependency
|
||||
Add `sqlx` with the `sqlite` and `runtime-async-std` features to `Cargo.toml`.
|
||||
|
||||
### store.rs additions
|
||||
New async function `open_cache(root: &Path) -> Result<sqlx::SqlitePool>`:
|
||||
- Opens (or creates) `.nbd/cache.db`.
|
||||
- Runs a migration: `CREATE TABLE IF NOT EXISTS tickets (id TEXT PRIMARY KEY, json TEXT NOT NULL, mtime INTEGER NOT NULL)`.
|
||||
|
||||
New function `list_tickets_cached(root: &Path) -> Result<Vec<Ticket>>`:
|
||||
1. Open cache.
|
||||
2. Read directory listing to get file names and mtimes.
|
||||
3. For each file: if the DB has a row with matching mtime, use cached JSON; otherwise read file, parse, insert/update row.
|
||||
4. Delete DB rows for IDs no longer on disk.
|
||||
5. Return deserialized tickets sorted by priority desc.
|
||||
|
||||
Keep existing `list_tickets` as the non-cached fallback. Consider making `cmd_list` and `cmd_ready` use `list_tickets_cached` when available.
|
||||
|
||||
### Migration strategy
|
||||
- The cache is always optional. If `cache.db` can't be opened, fall back to `list_tickets` (log a warning to stderr).
|
||||
- The cache is never the source of truth — the JSON files are. The cache is always reconstructable by deleting `.nbd/cache.db`.
|
||||
|
||||
## Decision point
|
||||
Decide whether to enable the cache unconditionally or gate it behind a flag (`--cache` / `NBD_CACHE=1`). Recommendation: enable by default once the feature is stable.
|
||||
|
||||
## Tests
|
||||
- Unit test: cache hit returns same data as direct file read.
|
||||
- Unit test: cache miss (mtime changed) re-reads the file.
|
||||
- Unit test: deleted ticket is evicted from cache.
|
||||
- Performance test (optional): benchmark 1000-ticket list with and without cache.
|
||||
|
||||
## Files touched
|
||||
- `Cargo.toml` — add `sqlx`
|
||||
- `src/store.rs` — `open_cache`, `list_tickets_cached`
|
||||
- `src/tests.rs` — cache unit tests
|
||||
- `docs/ARCHITECTURE.md` — document the cache layer
|
||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,150 @@
|
||||
+++
|
||||
title = "Wire --filter flag into list, ready, and migrate commands"
|
||||
priority = 8
|
||||
status = "done"
|
||||
ticket_type = "feature"
|
||||
dependencies = ["c2a024"]
|
||||
+++
|
||||
## Summary
|
||||
|
||||
Add `--filter KEY=VALUE` (repeatable) to the `list`, `ready`, and `migrate` CLI commands.
|
||||
Parse filter arguments into a `TicketFilter` and apply it in each command handler.
|
||||
|
||||
Depends on: TicketFilter module (ticket c2a024).
|
||||
|
||||
## CLI changes (src/main.rs)
|
||||
|
||||
Add to `Commands::List`, `Commands::Ready`, and `Commands::Migrate` variants:
|
||||
|
||||
```rust
|
||||
/// Filter tickets: key=value pairs (repeatable).
|
||||
/// Keys: priority, type, status, title.
|
||||
/// Different keys are ANDed; same key with multiple values is ORed.
|
||||
/// Values support glob wildcards: status=* matches all statuses.
|
||||
#[arg(long = "filter", value_name = "KEY=VALUE")]
|
||||
filter: Vec<String>,
|
||||
```
|
||||
|
||||
Update the `dispatch` function to pass filter args through to each handler.
|
||||
|
||||
## Handler changes
|
||||
|
||||
### cmd_list(filter_args, json)
|
||||
|
||||
```rust
|
||||
let filter = filter::parse_filters(&filter_args)?;
|
||||
let tickets: Vec<Ticket> = list_tickets(&root).await?
|
||||
.into_iter()
|
||||
.filter(|t| filter.matches(t))
|
||||
.collect();
|
||||
```
|
||||
|
||||
Note: the default done-exclusion behaviour (ticket for that is separate) will also
|
||||
live here, layered on top of this filter application.
|
||||
|
||||
### cmd_ready(filter_args, json)
|
||||
|
||||
Apply the user filter AFTER the ready check. The ready check (not done + all deps done)
|
||||
is always applied first; the user's filter narrows further within ready tickets.
|
||||
|
||||
```rust
|
||||
let filter = filter::parse_filters(&filter_args)?;
|
||||
// ... build done_ids as before ...
|
||||
let ready: Vec<Ticket> = all
|
||||
.into_iter()
|
||||
.filter(|t| {
|
||||
t.status \!= Status::Done
|
||||
&& t.dependencies.iter().all(|dep| done_ids.contains(dep.as_str()))
|
||||
&& filter.matches(t)
|
||||
})
|
||||
.collect();
|
||||
```
|
||||
|
||||
### cmd_migrate(filter_args, dry_run, json)
|
||||
|
||||
For migrate, the filter selects which tickets are candidates for migration.
|
||||
Tickets not matching the filter are skipped (counted separately, not treated as errors).
|
||||
|
||||
Add a `skipped` field to `MigrateReport` in `store.rs`:
|
||||
|
||||
```rust
|
||||
pub struct MigrateReport {
|
||||
pub updated: usize,
|
||||
pub already_current: usize,
|
||||
pub skipped: usize, // NEW: tickets excluded by filter
|
||||
pub errors: Vec<(String, String)>,
|
||||
}
|
||||
```
|
||||
|
||||
Update `migrate_tickets` signature in `store.rs`:
|
||||
|
||||
```rust
|
||||
pub async fn migrate_tickets(
|
||||
root: &Path,
|
||||
dry_run: bool,
|
||||
filter: &TicketFilter,
|
||||
) -> Result<MigrateReport>
|
||||
```
|
||||
|
||||
Inside the per-file loop, after deserialising the ticket, check `filter.matches(&ticket)`.
|
||||
If false: increment `report.skipped` and continue to next file.
|
||||
|
||||
Update `cmd_migrate` to parse the filter and pass it to `migrate_tickets`.
|
||||
|
||||
## display.rs changes
|
||||
|
||||
Update `format_migrate_report` and `format_migrate_report_json` to include the
|
||||
`skipped` count:
|
||||
|
||||
Human format:
|
||||
```
|
||||
Migrated 3 tickets.
|
||||
Current 5 tickets (already up to date).
|
||||
Skipped 2 tickets (did not match filter).
|
||||
Errors 1 ticket could not be migrated:
|
||||
bad_ticket.json: trailing comma at line 4
|
||||
```
|
||||
|
||||
Only print the "Skipped" line when `skipped > 0`.
|
||||
|
||||
JSON format: add `"skipped": N` key to the existing object.
|
||||
|
||||
## files touched
|
||||
|
||||
- `src/main.rs` — `filter` fields on List/Ready/Migrate variants, updated dispatch,
|
||||
updated cmd_list/cmd_ready/cmd_migrate handlers
|
||||
- `src/store.rs` — `MigrateReport::skipped`, `migrate_tickets` gains `filter` param
|
||||
- `src/display.rs` — updated `format_migrate_report` and `format_migrate_report_json`
|
||||
- `src/tests.rs` — unit tests for updated migrate report formatting
|
||||
- `tests/integration.rs` — integration tests
|
||||
|
||||
## Integration tests to add (tests/integration.rs)
|
||||
|
||||
**list filtering:**
|
||||
- Create tickets: 1 bug/todo, 1 task/in_progress, 1 bug/done.
|
||||
`nbd list --filter type=bug` shows only the bug tickets (done-exclusion is separate,
|
||||
but this test can use non-done bugs).
|
||||
- `nbd list --filter status=in_progress` shows only in_progress tickets.
|
||||
- `nbd list --filter status=todo --filter status=in_progress` shows both todo and in_progress
|
||||
(OR within same key).
|
||||
- `nbd list --filter type=bug --filter status=todo` shows only bug+todo tickets
|
||||
(AND across keys).
|
||||
- `nbd list --filter title=*login*` shows only tickets whose title contains "login".
|
||||
- `nbd list --filter status=*` matches all statuses (wildcard).
|
||||
- `nbd list --filter type=unknown` exits non-zero with an error (unknown key passes through
|
||||
as a value, but "unknown" does not match any type → empty results, or error? Error on
|
||||
unknown key is preferable).
|
||||
- `nbd list --filter badformat` (no `=`) exits non-zero with an error.
|
||||
|
||||
**ready filtering:**
|
||||
- `nbd ready --filter type=bug` returns only ready bug tickets.
|
||||
- `nbd ready --filter priority=8` returns only ready tickets with priority 8.
|
||||
|
||||
**migrate filtering:**
|
||||
- Create two tickets. Run `nbd migrate --filter status=todo --dry-run`.
|
||||
Verify `skipped` count in JSON output matches tickets not matching the filter.
|
||||
- `nbd migrate --filter status=todo --json` includes `skipped` key.
|
||||
|
||||
**error cases:**
|
||||
- `--filter` with unknown key exits non-zero.
|
||||
- `--filter` with no `=` exits non-zero.
|
||||
@ -1,10 +0,0 @@
|
||||
{
|
||||
"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<Ticket> = 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<String>,\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"
|
||||
}
|
||||
@ -0,0 +1,133 @@
|
||||
+++
|
||||
title = "Exclude done tickets from nbd list by default"
|
||||
priority = 7
|
||||
status = "done"
|
||||
ticket_type = "feature"
|
||||
dependencies = ["887344"]
|
||||
+++
|
||||
## Summary
|
||||
|
||||
Change `nbd list` to hide `done` tickets by default. Users must explicitly opt in
|
||||
via `--filter status=done` or `--filter status=*` to see completed tickets.
|
||||
|
||||
This is a **breaking change in default behavior**.
|
||||
|
||||
Depends on: `--filter` wired into `list` (ticket 887344).
|
||||
|
||||
## Current behaviour
|
||||
|
||||
`nbd list` shows ALL tickets regardless of status.
|
||||
|
||||
## New behaviour
|
||||
|
||||
| Command | Shows |
|
||||
|---|---|
|
||||
| `nbd list` | todo + in_progress only (done excluded) |
|
||||
| `nbd list --filter status=done` | done only |
|
||||
| `nbd list --filter status=*` | all tickets (all statuses) |
|
||||
| `nbd list --filter status=todo --filter status=in_progress` | todo and in_progress |
|
||||
| `nbd list --filter type=bug` | non-done bug tickets (done still excluded) |
|
||||
| `nbd list --filter type=bug --filter status=*` | all bug tickets including done |
|
||||
|
||||
The key rule: **if the user provides no `status` filter key, done tickets are excluded**.
|
||||
If the user provides any `--filter status=...` argument, the explicit status filter
|
||||
is used as-is with no implicit exclusion.
|
||||
|
||||
## Implementation (src/main.rs)
|
||||
|
||||
In `cmd_list`, after parsing the filter and loading tickets, apply the logic:
|
||||
|
||||
```rust
|
||||
let filter = filter::parse_filters(&filter_args)?;
|
||||
let tickets = list_tickets(&root).await?;
|
||||
|
||||
let tickets: Vec<Ticket> = tickets
|
||||
.into_iter()
|
||||
.filter(|t| {
|
||||
// If no status filter was provided by the user, exclude done tickets.
|
||||
let status_ok = if filter.has_status_filter() {
|
||||
// User expressed intent about status: use their filter.
|
||||
filter.matches_status(t)
|
||||
} else {
|
||||
// Default: hide done tickets.
|
||||
t.status \!= Status::Done
|
||||
};
|
||||
|
||||
// Apply remaining filter keys (type, priority, title) regardless.
|
||||
status_ok && filter.matches_except_status(t)
|
||||
})
|
||||
.collect();
|
||||
```
|
||||
|
||||
This requires two additional methods on `TicketFilter` (add to `src/filter.rs`):
|
||||
|
||||
```rust
|
||||
/// Returns true if the ticket's status matches any of the status patterns.
|
||||
/// Caller is responsible for only calling this when `has_status_filter()` is true.
|
||||
pub fn matches_status(&self, ticket: &Ticket) -> bool;
|
||||
|
||||
/// Returns true if the ticket matches all non-status filter groups (type, priority, title).
|
||||
/// The status group is intentionally excluded so callers can handle it separately.
|
||||
pub fn matches_except_status(&self, ticket: &Ticket) -> bool;
|
||||
```
|
||||
|
||||
## CLI help text update
|
||||
|
||||
Update the `List` variant doc comment:
|
||||
|
||||
```rust
|
||||
/// List tickets sorted by priority (highest first).
|
||||
///
|
||||
/// By default, tickets with status `done` are excluded. Use
|
||||
/// `--filter status=*` to include all tickets, or
|
||||
/// `--filter status=done` to show only completed tickets.
|
||||
List {
|
||||
#[arg(long = "filter", value_name = "KEY=VALUE")]
|
||||
filter: Vec<String>,
|
||||
},
|
||||
```
|
||||
|
||||
## Existing tests that need updating
|
||||
|
||||
The integration test `list_shows_created_tickets` creates two tickets with default
|
||||
status (`todo`) and asserts both appear in `nbd list`. This test is unaffected because
|
||||
the default tickets are not done. However, any future test that creates a ticket and
|
||||
immediately lists without marking it done will still work.
|
||||
|
||||
Check: is there any existing test that creates a done ticket and expects it in `nbd list`?
|
||||
If so, update that test to use `--filter status=done` or `--filter status=*`.
|
||||
|
||||
## New integration tests to add (tests/integration.rs)
|
||||
|
||||
- Create two tickets: one todo, one done. `nbd list` shows only the todo one.
|
||||
- Create two tickets: one todo, one done. `nbd list --filter status=done` shows only the done one.
|
||||
- Create two tickets: one todo, one done. `nbd list --filter status=*` shows both.
|
||||
- Create 3 tickets: bug/todo, bug/done, task/todo.
|
||||
`nbd list --filter type=bug` shows only bug/todo (done excluded by default).
|
||||
`nbd list --filter type=bug --filter status=*` shows both bug tickets.
|
||||
- `nbd list --json` does not include done tickets (verify JSON array length).
|
||||
- `nbd list --filter status=* --json` includes done tickets.
|
||||
|
||||
## README update
|
||||
|
||||
Update the "List all tickets" section in README.md:
|
||||
|
||||
```
|
||||
### List all tickets
|
||||
|
||||
```sh
|
||||
nbd list # excludes done tickets
|
||||
nbd list --filter status=* # all tickets including done
|
||||
nbd list --filter status=done # only completed tickets
|
||||
nbd list --filter type=bug # non-done bug tickets
|
||||
nbd list --json
|
||||
```
|
||||
```
|
||||
|
||||
## Files touched
|
||||
|
||||
- `src/main.rs` — `cmd_list` implementation and `List` help text
|
||||
- `src/filter.rs` — `matches_status`, `matches_except_status` methods
|
||||
- `src/tests.rs` — unit tests for new filter methods
|
||||
- `tests/integration.rs` — new tests, update any affected existing tests
|
||||
- `README.md` — updated usage section
|
||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,132 @@
|
||||
+++
|
||||
title = "Implement TicketFilter module with glob matching"
|
||||
priority = 8
|
||||
status = "done"
|
||||
ticket_type = "feature"
|
||||
dependencies = []
|
||||
+++
|
||||
## Summary
|
||||
|
||||
Add a `src/filter.rs` module implementing `TicketFilter`, which parses and applies
|
||||
`key=value` filter expressions against a list of tickets. This is the foundational
|
||||
building block for the `--filter` flag on `list`, `ready`, `migrate`, and `next` commands.
|
||||
|
||||
No external crate is needed — glob matching is implemented with a simple hand-rolled
|
||||
algorithm that handles `*` wildcards.
|
||||
|
||||
## Filter semantics
|
||||
|
||||
- `--filter key=value` can be specified multiple times.
|
||||
- Keys: `priority`, `type`, `status`, `title`.
|
||||
- **Different keys are ANDed**: `--filter type=bug --filter status=todo` matches tickets
|
||||
that are BOTH bugs AND todo.
|
||||
- **Same key, multiple values are ORed**: `--filter status=todo --filter status=in_progress`
|
||||
matches tickets with EITHER status.
|
||||
- Values support `*` as a wildcard anywhere in the pattern:
|
||||
- `status=*` matches any status
|
||||
- `title=*command*` matches any title containing "command"
|
||||
- `priority=7` matches exactly priority 7 (as string comparison)
|
||||
|
||||
## Data structures
|
||||
|
||||
```rust
|
||||
/// A compiled set of filter expressions applied to tickets.
|
||||
///
|
||||
/// Within the same key, patterns are ORed (any match passes the group).
|
||||
/// Across different keys, groups are ANDed (all non-empty groups must pass).
|
||||
pub struct TicketFilter {
|
||||
/// Glob patterns for `status` (OR within this group).
|
||||
pub status: Vec<String>,
|
||||
/// Glob patterns for `ticket_type` (OR within this group).
|
||||
pub ticket_type: Vec<String>,
|
||||
/// Glob patterns for `priority` (OR within this group; matched against string repr).
|
||||
pub priority: Vec<String>,
|
||||
/// Glob patterns for `title` (OR within this group).
|
||||
pub title: Vec<String>,
|
||||
}
|
||||
```
|
||||
|
||||
An empty `Vec` for a key means "no filter on that key" — matches everything.
|
||||
|
||||
## API
|
||||
|
||||
```rust
|
||||
/// Parse a slice of "key=value" strings into a TicketFilter.
|
||||
///
|
||||
/// The key `type` maps to the `ticket_type` field.
|
||||
/// Returns an error for unknown keys or malformed expressions (no '=').
|
||||
pub fn parse_filters(args: &[String]) -> crate::store::Result<TicketFilter>
|
||||
|
||||
impl TicketFilter {
|
||||
/// Returns true when all non-empty filter groups match this ticket.
|
||||
/// An empty TicketFilter always returns true.
|
||||
pub fn matches(&self, ticket: &crate::ticket::Ticket) -> bool;
|
||||
|
||||
/// Returns true when no filter groups are set (no-op filter).
|
||||
pub fn is_empty(&self) -> bool;
|
||||
|
||||
/// Returns true when the user has provided at least one status pattern.
|
||||
/// Used by cmd_list to detect whether to apply the implicit done-exclusion.
|
||||
pub fn has_status_filter(&self) -> bool;
|
||||
}
|
||||
```
|
||||
|
||||
## Glob algorithm (no external crate)
|
||||
|
||||
Implement a private `fn glob_matches(pattern: &str, value: &str) -> bool`:
|
||||
|
||||
1. If pattern contains no `*`, require exact equality.
|
||||
2. Split pattern on `*` into segments.
|
||||
3. If pattern does NOT start with `*`, value must start with `segments[0]`.
|
||||
4. If pattern does NOT end with `*`, value must end with `segments[last]`.
|
||||
5. For each remaining segment, find it in the remaining suffix of value (left to right).
|
||||
Advance past the match and continue. If any segment is not found, return false.
|
||||
|
||||
This handles: `*`, `foo`, `foo*`, `*foo`, `*foo*`, `foo*bar`, `*foo*bar*`.
|
||||
|
||||
Case sensitivity:
|
||||
- `status` and `type` patterns: case-insensitive (compare lowercase).
|
||||
- `title` and `priority` patterns: case-sensitive.
|
||||
|
||||
## matches() logic
|
||||
|
||||
```
|
||||
fn matches(&self, ticket) -> bool {
|
||||
(self.status.is_empty() || self.status.iter().any(|p| glob_matches(p, status_str(&ticket.status))))
|
||||
&& (self.ticket_type.is_empty() || self.ticket_type.iter().any(|p| glob_matches(p, ticket_type_str(&ticket.ticket_type))))
|
||||
&& (self.priority.is_empty() || self.priority.iter().any(|p| glob_matches(p, &ticket.priority.to_string())))
|
||||
&& (self.title.is_empty() || self.title.iter().any(|p| glob_matches(p, &ticket.title)))
|
||||
}
|
||||
```
|
||||
|
||||
Use `status_str` and `ticket_type_str` equivalents (can be private functions in filter.rs
|
||||
or call into display, or duplicate the small match arms).
|
||||
|
||||
## Module registration
|
||||
|
||||
Add `mod filter;` to `src/main.rs` (or move to `lib.rs` if the project ever gains one).
|
||||
The module is `pub(crate)`.
|
||||
|
||||
## Files touched
|
||||
|
||||
- `src/filter.rs` — new module
|
||||
- `src/main.rs` — add `mod filter;`
|
||||
- `src/tests.rs` — unit tests
|
||||
|
||||
## Unit tests to write (src/tests.rs)
|
||||
|
||||
- `parse_filters` rejects unknown keys with a descriptive error.
|
||||
- `parse_filters` rejects a string with no `=`.
|
||||
- `parse_filters` with `key=value=more` treats everything after first `=` as the value.
|
||||
- `glob_matches("*", "anything")` → true.
|
||||
- `glob_matches("*", "")` → true.
|
||||
- `glob_matches("todo", "todo")` → true; `glob_matches("todo", "done")` → false.
|
||||
- `glob_matches("*command*", "add command here")` → true.
|
||||
- `glob_matches("*command*", "no match")` → false.
|
||||
- `glob_matches("in_*", "in_progress")` → true.
|
||||
- `glob_matches("in_*", "todo")` → false.
|
||||
- `TicketFilter::matches` — two different keys AND correctly (both must match).
|
||||
- `TicketFilter::matches` — same key OR correctly (either matches).
|
||||
- `TicketFilter::matches` — empty filter matches everything.
|
||||
- `TicketFilter::is_empty` — true when no filters, false when any filter set.
|
||||
- `TicketFilter::has_status_filter` — true iff status vec is non-empty.
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"title": "Partial ID matching",
|
||||
"body": "Allow `nbd read`, `nbd update`, and dependency resolution to accept a prefix of a ticket ID (e.g. `nbd read a3f` resolves to `a3f9c2`).\n\n## Motivation\n\n6-character hex IDs are tedious to type in full. Prefix matching — like git's short-SHA resolution — significantly improves interactive ergonomics and makes agent-generated commands shorter.\n\n## Approach\n\nAdd `resolve_id(root: &Path, id_or_prefix: &str) -> Result<String>` in `store.rs`:\n1. If `id_or_prefix` is exactly 6 characters, try `read_ticket` as-is (fast path, existing behaviour).\n2. Otherwise (or if not found), scan `.nbd/tickets/` for files whose stem starts with `id_or_prefix`.\n3. Collect all matches.\n - 0 matches → error: `\"no ticket found matching '{prefix}'\"`\n - 1 match → return the full ID\n - 2+ matches → error: `\"ambiguous prefix '{prefix}' matches: {id1}, {id2}, ...\"`\n\nUse `resolve_id` inside `cmd_read` and `cmd_update` (replacing the bare `id` string passed to `read_ticket`). Also use it inside `validate_deps` so dependency flags can use short IDs too.\n\n## Tests\n\nUnit tests in `src/tests.rs`:\n- Exact 6-char match still works.\n- 3-char prefix resolves correctly.\n- Ambiguous prefix returns an error listing all matching IDs.\n- Unknown prefix returns a not-found error.\n\nIntegration tests in `tests/integration.rs`:\n- `nbd read <3-char-prefix>` resolves and prints the ticket.\n- `nbd update <3-char-prefix> --status done` succeeds.\n- Ambiguous prefix exits non-zero with an informative message.\n\n## Files touched\n- `src/store.rs` — new `resolve_id` function\n- `src/main.rs` — `cmd_read`, `cmd_update`, `validate_deps` use `resolve_id`\n- `src/tests.rs` — unit tests\n- `tests/integration.rs` — integration tests",
|
||||
"priority": 8,
|
||||
"status": "done",
|
||||
"dependencies": [],
|
||||
"ticket_type": "feature"
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
+++
|
||||
title = "Partial ID matching"
|
||||
priority = 8
|
||||
status = "done"
|
||||
ticket_type = "feature"
|
||||
dependencies = []
|
||||
+++
|
||||
Allow `nbd read`, `nbd update`, and dependency resolution to accept a prefix of a ticket ID (e.g. `nbd read a3f` resolves to `a3f9c2`).
|
||||
|
||||
## Motivation
|
||||
|
||||
6-character hex IDs are tedious to type in full. Prefix matching — like git's short-SHA resolution — significantly improves interactive ergonomics and makes agent-generated commands shorter.
|
||||
|
||||
## Approach
|
||||
|
||||
Add `resolve_id(root: &Path, id_or_prefix: &str) -> Result<String>` in `store.rs`:
|
||||
1. If `id_or_prefix` is exactly 6 characters, try `read_ticket` as-is (fast path, existing behaviour).
|
||||
2. Otherwise (or if not found), scan `.nbd/tickets/` for files whose stem starts with `id_or_prefix`.
|
||||
3. Collect all matches.
|
||||
- 0 matches → error: `"no ticket found matching '{prefix}'"`
|
||||
- 1 match → return the full ID
|
||||
- 2+ matches → error: `"ambiguous prefix '{prefix}' matches: {id1}, {id2}, ..."`
|
||||
|
||||
Use `resolve_id` inside `cmd_read` and `cmd_update` (replacing the bare `id` string passed to `read_ticket`). Also use it inside `validate_deps` so dependency flags can use short IDs too.
|
||||
|
||||
## Tests
|
||||
|
||||
Unit tests in `src/tests.rs`:
|
||||
- Exact 6-char match still works.
|
||||
- 3-char prefix resolves correctly.
|
||||
- Ambiguous prefix returns an error listing all matching IDs.
|
||||
- Unknown prefix returns a not-found error.
|
||||
|
||||
Integration tests in `tests/integration.rs`:
|
||||
- `nbd read <3-char-prefix>` resolves and prints the ticket.
|
||||
- `nbd update <3-char-prefix> --status done` succeeds.
|
||||
- Ambiguous prefix exits non-zero with an informative message.
|
||||
|
||||
## Files touched
|
||||
- `src/store.rs` — new `resolve_id` function
|
||||
- `src/main.rs` — `cmd_read`, `cmd_update`, `validate_deps` use `resolve_id`
|
||||
- `src/tests.rs` — unit tests
|
||||
- `tests/integration.rs` — integration tests
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"title": "Remove id field from ticket JSON body",
|
||||
"body": "The ticket ID is already encoded in the filename (`a3f9c2.json`). Storing it redundantly inside the JSON body creates a potential consistency hazard (the two could disagree) and wastes space. The filename should be the sole source of truth for the ID.\n\n## Motivation\n\n- Eliminates a redundancy: filename stem already IS the id.\n- Removes the risk of id mismatch (e.g. if a file is renamed manually).\n- Simplifies the JSON schema — consumers only need the body fields, not a duplicated key.\n\n## Serde approach\n\nIn `ticket.rs`, annotate the `id` field with `#[serde(skip)]`:\n\n```rust\n#[derive(Serialize, Deserialize, Debug, Clone)]\npub struct Ticket {\n #[serde(skip)]\n pub id: String,\n // ...\n}\n```\n\n`#[serde(skip)]` means:\n- **Serialise:** the `id` field is omitted from JSON output entirely.\n- **Deserialise:** the field is not read from JSON; it is initialised with `String::default()` (empty string) and must be set manually after deserialization.\n\nBecause serde ignores unknown fields by default, **existing files with `\"id\": \"...\"` in the JSON body continue to deserialise without error** — the field is simply discarded. This means the change is backwards-compatible for reads; existing stores work immediately. A separate `nbd migrate` command (see companion ticket) cleans up the stale `id` field from disk.\n\n## store.rs changes\n\n`read_ticket(root, id)` — inject id from the `id` parameter after deserialising:\n```rust\nlet mut ticket: Ticket = serde_json::from_slice(&bytes)?;\nticket.id = id.to_string(); // authoritative source: the filename\nOk(ticket)\n```\n\n`list_tickets(root)` — inject id from each file's stem:\n```rust\nlet stem = path.file_stem().and_then(|s| s.to_str()).ok_or(\"invalid filename\")?;\nlet mut ticket: Ticket = serde_json::from_slice(&bytes)?;\nticket.id = stem.to_string();\ntickets.push(ticket);\n```\n\n`write_ticket` — no change needed; `#[serde(skip)]` already prevents `id` from being written.\n\n`ticket_path` — no change; it already takes `id: &str` as a separate parameter.\n\n## Impact on other tickets\n\n- The `nbd migrate` companion ticket (see deps) provides the command to scrub the old `id` field from existing files.\n- Partial ID matching (`resolve_id`) is unaffected — it works on filenames, not JSON content.\n- All display, list, and read commands continue to work; `ticket.id` is populated from the filename in every read path.\n\n## Tests\n\nUnit tests (`src/tests.rs`):\n- `write_ticket` output does NOT contain the `\"id\"` key.\n- `read_ticket` with a file that has NO `id` field correctly sets `ticket.id` from the parameter.\n- `read_ticket` with an old-format file (has `\"id\"` in JSON) still sets `ticket.id` from the parameter (ignores JSON value).\n- `list_tickets` injects correct ids from filenames for all tickets.\n- Serialisation roundtrip: write then read, id is preserved via filename not JSON.\n\nIntegration tests (`tests/integration.rs`):\n- `nbd create` output (tabular and `--json`) contains the correct ID.\n- The created `.json` file on disk does NOT contain the `\"id\"` key.\n- `nbd read <id>` displays the correct ID.\n\n## Files touched\n- `src/ticket.rs` — add `#[serde(skip)]` to `id`\n- `src/store.rs` — `read_ticket` and `list_tickets` inject id from filename\n- `src/tests.rs` — update and add unit tests\n- `tests/integration.rs` — add assertion that written files lack `\"id\"` key",
|
||||
"priority": 9,
|
||||
"status": "done",
|
||||
"dependencies": [],
|
||||
"ticket_type": "task"
|
||||
}
|
||||
@ -0,0 +1,80 @@
|
||||
+++
|
||||
title = "Remove id field from ticket JSON body"
|
||||
priority = 9
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = []
|
||||
+++
|
||||
The ticket ID is already encoded in the filename (`a3f9c2.json`). Storing it redundantly inside the JSON body creates a potential consistency hazard (the two could disagree) and wastes space. The filename should be the sole source of truth for the ID.
|
||||
|
||||
## Motivation
|
||||
|
||||
- Eliminates a redundancy: filename stem already IS the id.
|
||||
- Removes the risk of id mismatch (e.g. if a file is renamed manually).
|
||||
- Simplifies the JSON schema — consumers only need the body fields, not a duplicated key.
|
||||
|
||||
## Serde approach
|
||||
|
||||
In `ticket.rs`, annotate the `id` field with `#[serde(skip)]`:
|
||||
|
||||
```rust
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct Ticket {
|
||||
#[serde(skip)]
|
||||
pub id: String,
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
`#[serde(skip)]` means:
|
||||
- **Serialise:** the `id` field is omitted from JSON output entirely.
|
||||
- **Deserialise:** the field is not read from JSON; it is initialised with `String::default()` (empty string) and must be set manually after deserialization.
|
||||
|
||||
Because serde ignores unknown fields by default, **existing files with `"id": "..."` in the JSON body continue to deserialise without error** — the field is simply discarded. This means the change is backwards-compatible for reads; existing stores work immediately. A separate `nbd migrate` command (see companion ticket) cleans up the stale `id` field from disk.
|
||||
|
||||
## store.rs changes
|
||||
|
||||
`read_ticket(root, id)` — inject id from the `id` parameter after deserialising:
|
||||
```rust
|
||||
let mut ticket: Ticket = serde_json::from_slice(&bytes)?;
|
||||
ticket.id = id.to_string(); // authoritative source: the filename
|
||||
Ok(ticket)
|
||||
```
|
||||
|
||||
`list_tickets(root)` — inject id from each file's stem:
|
||||
```rust
|
||||
let stem = path.file_stem().and_then(|s| s.to_str()).ok_or("invalid filename")?;
|
||||
let mut ticket: Ticket = serde_json::from_slice(&bytes)?;
|
||||
ticket.id = stem.to_string();
|
||||
tickets.push(ticket);
|
||||
```
|
||||
|
||||
`write_ticket` — no change needed; `#[serde(skip)]` already prevents `id` from being written.
|
||||
|
||||
`ticket_path` — no change; it already takes `id: &str` as a separate parameter.
|
||||
|
||||
## Impact on other tickets
|
||||
|
||||
- The `nbd migrate` companion ticket (see deps) provides the command to scrub the old `id` field from existing files.
|
||||
- Partial ID matching (`resolve_id`) is unaffected — it works on filenames, not JSON content.
|
||||
- All display, list, and read commands continue to work; `ticket.id` is populated from the filename in every read path.
|
||||
|
||||
## Tests
|
||||
|
||||
Unit tests (`src/tests.rs`):
|
||||
- `write_ticket` output does NOT contain the `"id"` key.
|
||||
- `read_ticket` with a file that has NO `id` field correctly sets `ticket.id` from the parameter.
|
||||
- `read_ticket` with an old-format file (has `"id"` in JSON) still sets `ticket.id` from the parameter (ignores JSON value).
|
||||
- `list_tickets` injects correct ids from filenames for all tickets.
|
||||
- Serialisation roundtrip: write then read, id is preserved via filename not JSON.
|
||||
|
||||
Integration tests (`tests/integration.rs`):
|
||||
- `nbd create` output (tabular and `--json`) contains the correct ID.
|
||||
- The created `.json` file on disk does NOT contain the `"id"` key.
|
||||
- `nbd read <id>` displays the correct ID.
|
||||
|
||||
## Files touched
|
||||
- `src/ticket.rs` — add `#[serde(skip)]` to `id`
|
||||
- `src/store.rs` — `read_ticket` and `list_tickets` inject id from filename
|
||||
- `src/tests.rs` — update and add unit tests
|
||||
- `tests/integration.rs` — add assertion that written files lack `"id"` key
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"title": "nbd ready command",
|
||||
"body": "Add `nbd ready` subcommand that lists tickets which are actionable right now: not yet done and with all dependencies completed.\n\n## Motivation\n\nAgent workflows need to know which tickets are unblocked. `nbd list` shows everything; `nbd ready` narrows to what can actually be started immediately.\n\n## Approach\n\n1. Add `Ready` variant to `Commands` enum in `main.rs`.\n2. Implement `cmd_ready(json: bool)`:\n a. `list_tickets(root)` to fetch all tickets.\n b. Build a set of IDs for tickets with `status == Status::Done`.\n c. Filter to tickets where:\n - `ticket.status != Status::Done` (not already finished)\n - All IDs in `ticket.dependencies` are in the done-set (or the dep doesn't exist — treat missing deps as unresolved, not ready).\n d. Print the filtered slice using existing `display::print_list` / `print_list_json`.\n3. No new store or display functions needed — reuse existing.\n\n## Edge cases\n- A ticket with no dependencies and status `todo` → ready.\n- A ticket whose dep is `in_progress` → NOT ready.\n- Missing dep ID → NOT ready (treat conservatively).\n- Empty store → returns empty list (not an error).\n\n## Tests\n\nUnit-style integration tests:\n- Three tickets: A (no deps, todo), B (dep A, todo), C (no deps, done). `nbd ready` should return only A.\n- After marking A done, `nbd ready` should return B.\n- `nbd ready --json` returns a JSON array of the ready tickets.\n\n## Files touched\n- `src/main.rs` — new `Ready` variant and `cmd_ready` handler\n- `tests/integration.rs` — new integration tests",
|
||||
"priority": 7,
|
||||
"status": "done",
|
||||
"dependencies": [],
|
||||
"ticket_type": "feature"
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
+++
|
||||
title = "nbd ready command"
|
||||
priority = 7
|
||||
status = "done"
|
||||
ticket_type = "feature"
|
||||
dependencies = []
|
||||
+++
|
||||
Add `nbd ready` subcommand that lists tickets which are actionable right now: not yet done and with all dependencies completed.
|
||||
|
||||
## Motivation
|
||||
|
||||
Agent workflows need to know which tickets are unblocked. `nbd list` shows everything; `nbd ready` narrows to what can actually be started immediately.
|
||||
|
||||
## Approach
|
||||
|
||||
1. Add `Ready` variant to `Commands` enum in `main.rs`.
|
||||
2. Implement `cmd_ready(json: bool)`:
|
||||
a. `list_tickets(root)` to fetch all tickets.
|
||||
b. Build a set of IDs for tickets with `status == Status::Done`.
|
||||
c. Filter to tickets where:
|
||||
- `ticket.status != Status::Done` (not already finished)
|
||||
- All IDs in `ticket.dependencies` are in the done-set (or the dep doesn't exist — treat missing deps as unresolved, not ready).
|
||||
d. Print the filtered slice using existing `display::print_list` / `print_list_json`.
|
||||
3. No new store or display functions needed — reuse existing.
|
||||
|
||||
## Edge cases
|
||||
- A ticket with no dependencies and status `todo` → ready.
|
||||
- A ticket whose dep is `in_progress` → NOT ready.
|
||||
- Missing dep ID → NOT ready (treat conservatively).
|
||||
- Empty store → returns empty list (not an error).
|
||||
|
||||
## Tests
|
||||
|
||||
Unit-style integration tests:
|
||||
- Three tickets: A (no deps, todo), B (dep A, todo), C (no deps, done). `nbd ready` should return only A.
|
||||
- After marking A done, `nbd ready` should return B.
|
||||
- `nbd ready --json` returns a JSON array of the ready tickets.
|
||||
|
||||
## Files touched
|
||||
- `src/main.rs` — new `Ready` variant and `cmd_ready` handler
|
||||
- `tests/integration.rs` — new integration tests
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"title": "nbd claude-md command",
|
||||
"body": "Add `nbd claude-md` subcommand that prints a ready-to-paste CLAUDE.md snippet for adopting `nbd` in any project. The snippet content is maintained as a source file and baked into the binary at compile time via `include_str!`.\n\n## Motivation\n\nEvery project that wants to use `nbd` needs the same boilerplate in its CLAUDE.md: what `nbd` is, how to invoke it, the create/update/done workflow, and the key guidelines. Without this command, that block has to be written by hand and tends to drift out of date as `nbd` evolves. By owning the canonical snippet in the `nbd` source tree and embedding it into the binary, the snippet stays in sync with the tool automatically — projects just run `nbd claude-md >> CLAUDE.md` when they adopt or upgrade `nbd`.\n\n## Snippet file\n\nCreate `src/claude_md_snippet.md`. This is a plain markdown file, checked into the repository, that contains the CLAUDE.md section any adopting project should paste in. It should cover:\n\n- One-sentence description of `nbd`.\n- Initialisation (`nbd init`).\n- The four core commands with examples (`create`, `list`, `read`, `update`).\n- The `nbd ready` command for finding unblocked tickets.\n- The workflow (create before starting → set in_progress → set done).\n- Guidelines: always `--json`, priority scale, type choices, dep usage.\n\nThe file is written for a project where `nbd` is installed in PATH (i.e. not via `cargo run`). It should be self-contained — no references to the `nbd` crate internals.\n\n## Binary embedding\n\nIn `main.rs`, embed the snippet at compile time:\n\n```rust\nconst CLAUDE_MD_SNIPPET: &str = include_str!(\"claude_md_snippet.md\");\n```\n\n`include_str!` resolves paths relative to the source file (`src/`), so this looks for `src/claude_md_snippet.md`. Cargo rebuilds the binary automatically when the file changes.\n\n## Command implementation\n\nAdd `ClaudeMd` variant to `Commands` in `main.rs`:\n\n```rust\n/// Print a CLAUDE.md snippet for adopting nbd in a project.\nClaudeMd,\n```\n\nThe `--json` global flag applies:\n- Without `--json`: `print!(\"{CLAUDE_MD_SNIPPET}\")` — raw markdown, suitable for redirect (`nbd claude-md >> CLAUDE.md`).\n- With `--json`: output `{\"snippet\": \"<escaped content>\"}` — for programmatic consumption.\n\nNo store access needed — this command is pure output from the embedded constant. It does not call `find_nbd_root()`.\n\n## No display.rs changes needed\n\nThe output is a single `print!` call in the command handler. No tabular formatting.\n\n## Tests\n\nIntegration tests (`tests/integration.rs`):\n- `nbd claude-md` exits zero and stdout is non-empty.\n- stdout contains key strings (`\"nbd\"`, `\"CLAUDE.md\"` or similar section header, `\"--json\"`).\n- `nbd claude-md --json` exits zero and stdout is valid JSON with a `\"snippet\"` key whose value is a non-empty string.\n- `nbd claude-md` works even when run from a directory with no `.nbd/` (no `find_nbd_root` call).\n\n## Files touched\n- `src/claude_md_snippet.md` — new file; the canonical snippet content\n- `src/main.rs` — `include_str!` constant, `ClaudeMd` command variant and handler\n- `tests/integration.rs` — integration tests\n- `README.md` — mention `nbd claude-md` in the Usage section",
|
||||
"priority": 6,
|
||||
"status": "done",
|
||||
"dependencies": [],
|
||||
"ticket_type": "feature"
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
+++
|
||||
title = "nbd claude-md command"
|
||||
priority = 6
|
||||
status = "done"
|
||||
ticket_type = "feature"
|
||||
dependencies = []
|
||||
+++
|
||||
Add `nbd claude-md` subcommand that prints a ready-to-paste CLAUDE.md snippet for adopting `nbd` in any project. The snippet content is maintained as a source file and baked into the binary at compile time via `include_str!`.
|
||||
|
||||
## Motivation
|
||||
|
||||
Every project that wants to use `nbd` needs the same boilerplate in its CLAUDE.md: what `nbd` is, how to invoke it, the create/update/done workflow, and the key guidelines. Without this command, that block has to be written by hand and tends to drift out of date as `nbd` evolves. By owning the canonical snippet in the `nbd` source tree and embedding it into the binary, the snippet stays in sync with the tool automatically — projects just run `nbd claude-md >> CLAUDE.md` when they adopt or upgrade `nbd`.
|
||||
|
||||
## Snippet file
|
||||
|
||||
Create `src/claude_md_snippet.md`. This is a plain markdown file, checked into the repository, that contains the CLAUDE.md section any adopting project should paste in. It should cover:
|
||||
|
||||
- One-sentence description of `nbd`.
|
||||
- Initialisation (`nbd init`).
|
||||
- The four core commands with examples (`create`, `list`, `read`, `update`).
|
||||
- The `nbd ready` command for finding unblocked tickets.
|
||||
- The workflow (create before starting → set in_progress → set done).
|
||||
- Guidelines: always `--json`, priority scale, type choices, dep usage.
|
||||
|
||||
The file is written for a project where `nbd` is installed in PATH (i.e. not via `cargo run`). It should be self-contained — no references to the `nbd` crate internals.
|
||||
|
||||
## Binary embedding
|
||||
|
||||
In `main.rs`, embed the snippet at compile time:
|
||||
|
||||
```rust
|
||||
const CLAUDE_MD_SNIPPET: &str = include_str!("claude_md_snippet.md");
|
||||
```
|
||||
|
||||
`include_str!` resolves paths relative to the source file (`src/`), so this looks for `src/claude_md_snippet.md`. Cargo rebuilds the binary automatically when the file changes.
|
||||
|
||||
## Command implementation
|
||||
|
||||
Add `ClaudeMd` variant to `Commands` in `main.rs`:
|
||||
|
||||
```rust
|
||||
/// Print a CLAUDE.md snippet for adopting nbd in a project.
|
||||
ClaudeMd,
|
||||
```
|
||||
|
||||
The `--json` global flag applies:
|
||||
- Without `--json`: `print!("{CLAUDE_MD_SNIPPET}")` — raw markdown, suitable for redirect (`nbd claude-md >> CLAUDE.md`).
|
||||
- With `--json`: output `{"snippet": "<escaped content>"}` — for programmatic consumption.
|
||||
|
||||
No store access needed — this command is pure output from the embedded constant. It does not call `find_nbd_root()`.
|
||||
|
||||
## No display.rs changes needed
|
||||
|
||||
The output is a single `print!` call in the command handler. No tabular formatting.
|
||||
|
||||
## Tests
|
||||
|
||||
Integration tests (`tests/integration.rs`):
|
||||
- `nbd claude-md` exits zero and stdout is non-empty.
|
||||
- stdout contains key strings (`"nbd"`, `"CLAUDE.md"` or similar section header, `"--json"`).
|
||||
- `nbd claude-md --json` exits zero and stdout is valid JSON with a `"snippet"` key whose value is a non-empty string.
|
||||
- `nbd claude-md` works even when run from a directory with no `.nbd/` (no `find_nbd_root` call).
|
||||
|
||||
## Files touched
|
||||
- `src/claude_md_snippet.md` — new file; the canonical snippet content
|
||||
- `src/main.rs` — `include_str!` constant, `ClaudeMd` command variant and handler
|
||||
- `tests/integration.rs` — integration tests
|
||||
- `README.md` — mention `nbd claude-md` in the Usage section
|
||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,179 @@
|
||||
+++
|
||||
title = "Add nbd next subcommand"
|
||||
priority = 7
|
||||
status = "done"
|
||||
ticket_type = "feature"
|
||||
dependencies = ["887344"]
|
||||
+++
|
||||
## Summary
|
||||
|
||||
Add `nbd next` subcommand that selects the single highest-priority ticket that is
|
||||
ready to work on. Supports `--filter` for additional narrowing within the ready set.
|
||||
|
||||
Depends on: `--filter` wired into CLI (ticket 887344). Implicitly depends on the
|
||||
TicketFilter module (ticket c2a024) through that.
|
||||
|
||||
## Definition of "ready"
|
||||
|
||||
Same semantics as `nbd ready`:
|
||||
- `status \!= done`
|
||||
- Every ID in `ticket.dependencies` belongs to a ticket with `status == done`.
|
||||
- Missing dependency IDs are treated conservatively: the ticket is NOT ready.
|
||||
|
||||
## Behaviour
|
||||
|
||||
1. Load all tickets with `list_tickets(&root)` (already sorted by priority desc).
|
||||
2. Build `done_ids` set (same as `cmd_ready`).
|
||||
3. Find the first ticket (highest priority) where:
|
||||
- status \!= done
|
||||
- all dependencies are in done_ids
|
||||
- `filter.matches(t)` (if a filter was provided)
|
||||
4. If found: display the single ticket.
|
||||
5. If not found: print a "no ready tickets" message. Exit 0 (not an error).
|
||||
|
||||
## Output
|
||||
|
||||
Without `--json`:
|
||||
- Found: print the ticket in the same tabular format as `nbd read` (via `display::print_ticket`).
|
||||
- Not found: `No ready tickets.`
|
||||
|
||||
With `--json`:
|
||||
- Found: `{"next": { ...ticket fields including id... }}`
|
||||
- Not found: `{"next": null}`
|
||||
|
||||
Wrapping in `{"next": ...}` (rather than bare ticket or bare null) makes the JSON
|
||||
unambiguously parseable by consumers — they always get an object with a `next` key.
|
||||
|
||||
## Implementation
|
||||
|
||||
Add to `Commands` enum:
|
||||
|
||||
```rust
|
||||
/// Choose the highest-priority ticket that is ready to work on.
|
||||
///
|
||||
/// A ticket is ready when its status is not `done` and every ticket it
|
||||
/// depends on has status `done`. Returns the single highest-priority
|
||||
/// ready ticket, optionally narrowed by `--filter KEY=VALUE`.
|
||||
///
|
||||
/// Exits 0 even when no ready ticket exists.
|
||||
Next {
|
||||
/// Filter ready tickets: key=value pairs (repeatable).
|
||||
/// AND between different keys, OR within same key.
|
||||
#[arg(long = "filter", value_name = "KEY=VALUE")]
|
||||
filter: Vec<String>,
|
||||
},
|
||||
```
|
||||
|
||||
Add dispatch arm:
|
||||
|
||||
```rust
|
||||
Commands::Next { filter } => cmd_next(filter, cli.json).await,
|
||||
```
|
||||
|
||||
Implement `cmd_next`:
|
||||
|
||||
```rust
|
||||
async fn cmd_next(filter_args: Vec<String>, json: bool) -> store::Result<()> {
|
||||
let root = find_nbd_root()?;
|
||||
let all = list_tickets(&root).await?; // sorted by priority desc
|
||||
let filter = filter::parse_filters(&filter_args)?;
|
||||
|
||||
let done_ids: std::collections::HashSet<&str> = all
|
||||
.iter()
|
||||
.filter(|t| t.status == Status::Done)
|
||||
.map(|t| t.id.as_str())
|
||||
.collect();
|
||||
|
||||
let next = all.iter().find(|t| {
|
||||
t.status \!= Status::Done
|
||||
&& t.dependencies.iter().all(|dep| done_ids.contains(dep.as_str()))
|
||||
&& filter.matches(t)
|
||||
});
|
||||
|
||||
if json {
|
||||
match next {
|
||||
Some(ticket) => {
|
||||
let value = serde_json::json\!({
|
||||
"next": display::ticket_to_json_value(ticket)
|
||||
});
|
||||
println\!("{}", serde_json::to_string_pretty(&value)?);
|
||||
}
|
||||
None => println\!("{}", serde_json::json\!({"next": null})),
|
||||
}
|
||||
} else {
|
||||
match next {
|
||||
Some(ticket) => display::print_ticket(ticket),
|
||||
None => println\!("No ready tickets."),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## display.rs: make ticket_to_json_value pub(crate)
|
||||
|
||||
`ticket_to_json_value` is currently private in `display.rs`. It needs to be accessible
|
||||
from `cmd_next` in `main.rs`. Change its visibility:
|
||||
|
||||
```rust
|
||||
pub(crate) fn ticket_to_json_value(ticket: &Ticket) -> serde_json::Value { ... }
|
||||
```
|
||||
|
||||
This is the cleanest approach — it reuses the existing id-injection logic rather than
|
||||
duplicating it.
|
||||
|
||||
## README update
|
||||
|
||||
Add a `### Find the next ticket to work on` section:
|
||||
|
||||
```markdown
|
||||
### Find the next ticket to work on
|
||||
|
||||
Returns the single highest-priority ticket that is ready to work on — not done
|
||||
and with all dependencies completed.
|
||||
|
||||
```sh
|
||||
nbd next
|
||||
nbd next --json
|
||||
nbd next --filter type=bug # highest-priority ready bug
|
||||
nbd next --filter priority=9 # highest-priority ready ticket with priority 9
|
||||
```
|
||||
|
||||
Exits 0 even when no ready ticket exists.
|
||||
```
|
||||
|
||||
## CLAUDE.md update
|
||||
|
||||
Update the "Workflow" section to mention `nbd next` as an alternative to `nbd ready`
|
||||
when the caller just wants to begin the single most important task:
|
||||
|
||||
```
|
||||
**To get the single best ticket to work on next:**
|
||||
|
||||
```sh
|
||||
cargo run -- next --json
|
||||
```
|
||||
```
|
||||
|
||||
## Files touched
|
||||
|
||||
- `src/main.rs` — `Next` command variant, `cmd_next` handler, dispatch arm
|
||||
- `src/display.rs` — `ticket_to_json_value` changed to `pub(crate)`
|
||||
- `tests/integration.rs` — integration tests
|
||||
- `README.md` — new section for `nbd next`
|
||||
- `CLAUDE.md` — update workflow section
|
||||
|
||||
## Integration tests to add (tests/integration.rs)
|
||||
|
||||
- Three tickets: A (priority 5, no deps), B (priority 8, dep A), C (priority 7, no deps).
|
||||
`nbd next --json` returns C (highest priority ready ticket — B is blocked by A).
|
||||
- After marking A done: `nbd next --json` returns B (priority 8, now unblocked).
|
||||
- With only done tickets: `nbd next --json` returns `{"next": null}`.
|
||||
- `nbd next` (no `--json`) with no ready tickets prints "No ready tickets." and exits 0.
|
||||
- `nbd next --filter type=bug --json`: create a bug and a task, both ready.
|
||||
Returns the bug if it's the highest-priority bug, otherwise the highest-priority bug.
|
||||
(Create bug priority 8, task priority 9: with filter, should return the bug.)
|
||||
- `nbd next --json` returns a JSON object with a `"next"` key containing all ticket fields
|
||||
including `"id"`.
|
||||
- `nbd next --filter priority=99 --json` returns `{"next": null}` (no ticket has priority 99).
|
||||
Loading…
Reference in New Issue