Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,22 @@

## [Unreleased]

### Fixed
- `update_yaml_field` now works on macOS/BSD (awk instead of GNU-specific sed for field insertion)
- `update` command for non-iterated freeform array fields (e.g. `release_notes`) no longer rejects values whose items contain commas. Storage uses structural quoting via `jq @json`, which roundtrips losslessly as valid YAML 1.2 flow sequences (also valid JSON arrays). `tk query` returns the values verbatim.
- `update_yaml_field` now uses `ENVIRON` instead of `awk -v` for the value, preserving JSON escape sequences (`\"`, `\\`, `\n`) byte-exact. Previously, `awk -v` would unescape those sequences and corrupt stored YAML when arrays contained quoted strings.

### Changed
- `update` command now emits valid YAML flow sequences with JSON-escaped values (via `jq @json`). Previously the output was bare CSV with no quoting, which lost value boundaries when items contained commas.
- `update` command now requires `jq` for JSON-array values (`--field='[…]'`). Previously, `jq` absence triggered a regex-based fallback that accepted commas-in-items inconsistently. The fail-fast error directs users to install `jq`. Scalar updates are unaffected.
- `ticket-query` now detects the new quoted form (`["bug", "urgent"]`) and emits it verbatim as already-valid JSON. Legacy unquoted `tickets/*.md` (`[bug, urgent]`) keep working via the existing comma-split branch — no migration required.
- Tag/deps/links readers in `cmd_ready`, `cmd_closed`, `cmd_blocked`, `cmd_show`, `cmd_dep_tree`, `cmd_dep_cycle`, and `plugins/ticket-ls` now widen the gsub character class from `[\[\] ]` to `[\[\] "]`, accepting both quoted and unquoted inline arrays.
- `update` command continues to reject comma-in-item for `tags` specifically (reader limitation: `tk ls -T`, `cmd_ready`, `cmd_blocked`, `cmd_show` split tag values on comma). The new error message names the reader limitation explicitly. Other freeform array fields are unaffected.
- Extracted `edit`, `ls`, `query`, and `migrate-beads` commands to plugins (ticket-extras)

### Added
- `update` command for non-interactive YAML field updates (`tk update <id> --field=value`)
- JSON array syntax support in update command (requires jq): `--tags='["a", "b"]'`
- Plugin system: executables named `tk-<cmd>` or `ticket-<cmd>` in PATH are invoked automatically
- `super` command to bypass plugins and run built-in commands directly
- `TICKETS_DIR` and `TK_SCRIPT` environment variables exported for plugins
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ Commands:
close <id> Set status to closed
reopen <id> Set status to open
status <id> <status> Update status (open|in_progress|closed)
update <id> --field=value Update YAML field(s) non-interactively
Arrays: --field='["a", "b"]' (JSON syntax)
dep <id> <dep-id> Add dependency (id depends on dep-id)
dep tree [--full] <id> Show dependency tree (--full disables dedup)
dep cycle Find dependency cycles in open tickets
Expand Down
11 changes: 8 additions & 3 deletions features/steps/ticket_steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,16 +356,18 @@ def step_output_empty(context):
assert context.stdout == '', f"Expected empty output but got: {context.stdout}"


@then(r'the output should contain "(?P<text>[^"]+)"')
@then(r'the output should contain "(?P<text>(?:[^"\\]|\\.)+)"')
def step_output_contains(context, text):
"""Assert output contains text."""
text = text.replace('\\"', '"').replace('\\\\', '\\')
output = context.stdout + context.stderr
assert text in output, f"Expected output to contain '{text}'\nActual output: {output}"


@then(r'the output should not contain "(?P<text>[^"]+)"')
@then(r'the output should not contain "(?P<text>(?:[^"\\]|\\.)+)"')
def step_output_not_contains(context, text):
"""Assert output does not contain text."""
text = text.replace('\\"', '"').replace('\\\\', '\\')
output = context.stdout + context.stderr
assert text not in output, f"Expected output to NOT contain '{text}'\nActual output: {output}"

Expand Down Expand Up @@ -456,9 +458,12 @@ def step_created_ticket_has_timestamp(context):
f"No valid created timestamp found\nContent: {content}"


@then(r'ticket "(?P<ticket_id>[^"]+)" should have field "(?P<field>[^"]+)" with value "(?P<value>[^"]+)"')
@then(r'ticket "(?P<ticket_id>[^"]+)" should have field "(?P<field>[^"]+)" with value "(?P<value>(?:[^"\\]|\\.)*)"')
def step_ticket_has_field_value(context, ticket_id, field, value):
"""Assert ticket has a field with specific value."""
# Unescape \" to " (and \\ to \) in the expected value, mirroring `I run`
value = value.replace('\\"', '"').replace('\\\\', '\\')

ticket_path = Path(context.test_dir) / '.tickets' / f'{ticket_id}.md'
content = ticket_path.read_text()

Expand Down
13 changes: 13 additions & 0 deletions features/ticket_listing.feature
Original file line number Diff line number Diff line change
Expand Up @@ -161,3 +161,16 @@ Feature: Ticket Listing
When I run "ticket closed"
Then the command should succeed
And the output should not contain "done-0001"

Scenario: ls -T filter works with quoted tags (after tk update)
Given a ticket exists with ID "tag-001" and title "Quoted tags"
When I run "ticket update tag-001 '--tags=[\"bug\",\"critical\"]'"
And I run "ticket ls -T critical"
Then the command should succeed
And the output should contain "tag-001"

Scenario: ls -T filter works with legacy unquoted tags (backward compat)
When I run "ticket create 'Legacy tags' --tags bug,urgent"
And I run "ticket ls -T urgent"
Then the command should succeed
And the output should contain "Legacy tags"
13 changes: 13 additions & 0 deletions features/ticket_query.feature
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,16 @@ Feature: Ticket Query
When I run "ticket query"
Then the command should succeed
And the JSONL deps field should be a JSON array

Scenario: Query emits valid JSON for new quoted tags (after tk update)
Given a ticket exists with ID "query-001" and title "Quoted tags"
When I run "ticket update query-001 '--tags=[\"bug\",\"urgent\"]'"
And I run "ticket query"
Then the command should succeed
And the output should contain "\"tags\":[\"bug\", \"urgent\"]"

Scenario: Query still handles legacy unquoted tags (bare CSV, backward compat)
When I run "ticket create 'Legacy tags' --tags bug,urgent"
And I run "ticket query"
Then the command should succeed
And the output should contain "\"tags\":[\"bug\",\"urgent\"]"
187 changes: 187 additions & 0 deletions features/ticket_update.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
Feature: Ticket Update Command
As a user or automation script
I want to update ticket fields non-interactively
So that I can modify tickets without opening an editor

Background:
Given a clean tickets directory
And a ticket exists with ID "test-0001" and title "Test ticket"

Scenario: Update existing field
When I run "ticket update test-0001 --priority=1"
Then the command should succeed
And the output should be "Updated 1 field(s) on test-0001"
And ticket "test-0001" should have field "priority" with value "1"

Scenario: Update multiple fields
When I run "ticket update test-0001 --priority=1 --assignee=alice"
Then the command should succeed
And the output should be "Updated 2 field(s) on test-0001"
And ticket "test-0001" should have field "priority" with value "1"
And ticket "test-0001" should have field "assignee" with value "alice"

Scenario: Add new custom field
When I run "ticket update test-0001 --custom_field=myvalue"
Then the command should succeed
And the output should be "Updated 1 field(s) on test-0001"
And ticket "test-0001" should have field "custom_field" with value "myvalue"

Scenario: Update with JSON array (requires jq)
When I run "ticket update test-0001 '--tags=[\"bug\",\"urgent\"]'"
Then the command should succeed
And the output should be "Updated 1 field(s) on test-0001"
And ticket "test-0001" should have field "tags" with value "[\"bug\", \"urgent\"]"

Scenario: JSON array with commas in values roundtrip
When I run "ticket update test-0001 '--release_notes=[\"First entry, with comma.\", \"Second entry.\"]'"
Then the command should succeed
And the output should be "Updated 1 field(s) on test-0001"
And ticket "test-0001" should have field "release_notes" with value "[\"First entry, with comma.\", \"Second entry.\"]"

Scenario: Invalid JSON array
When I run "ticket update test-0001 '--tags=[\"unclosed]'"
Then the command should fail
And the output should contain "Error: invalid JSON for tags"

Scenario: Unknown argument without --field=value format
When I run "ticket update test-0001 badarg"
Then the command should fail
And the output should contain "Error: unknown argument 'badarg'"

Scenario: Missing field arguments
When I run "ticket update test-0001"
Then the command should fail
And the output should contain "Usage:"

Scenario: Update non-existent ticket
When I run "ticket update nonexistent --priority=1"
Then the command should fail
And the output should contain "Error: ticket 'nonexistent' not found"

Scenario: Update with partial ID
When I run "ticket update 0001 --priority=0"
Then the command should succeed
And ticket "test-0001" should have field "priority" with value "0"

Scenario: Exit code is 0 on successful single update (set -e regression)
When I run "ticket update test-0001 --priority=1"
Then the command should succeed

Scenario: Exit code is 0 on successful multiple updates
When I run "ticket update test-0001 --priority=1 --assignee=alice"
Then the command should succeed

Scenario: Value containing ampersand is stored literally (sed & metachar)
When I run "ticket update test-0001 --assignee='A & B'"
Then the command should succeed
And ticket "test-0001" should have field "assignee" with value "A & B"

Scenario: Value containing forward slash is stored literally (sed / separator)
When I run "ticket update test-0001 --external-ref='https://example.com/path'"
Then the command should succeed
And ticket "test-0001" should have field "external-ref" with value "https://example.com/path"

Scenario: Cannot update id field (immutable)
When I run "ticket update test-0001 --id=hacker"
Then the command should fail
And the output should contain "Error: field 'id' is immutable"

Scenario: Cannot update created field (immutable)
When I run "ticket update test-0001 --created=2020-01-01"
Then the command should fail
And the output should contain "immutable"

Scenario: Cannot update status (routes to tk status)
When I run "ticket update test-0001 --status=closed"
Then the command should fail
And the output should contain "use 'tk status"

Scenario: Cannot update deps (routes to tk dep)
When I run "ticket update test-0001 '--deps=[\"other\"]'"
Then the command should fail
And the output should contain "use 'tk dep"

Scenario: Cannot update links (routes to tk link)
When I run "ticket update test-0001 '--links=[\"other\"]'"
Then the command should fail
And the output should contain "use 'tk link"

Scenario: Cannot update title (body content, not YAML)
When I run "ticket update test-0001 --title=Renamed"
Then the command should fail
And the output should contain "markdown body"

Scenario: Cannot update description (body content, not YAML)
When I run "ticket update test-0001 --description=text"
Then the command should fail
And the output should contain "markdown body"

Scenario: Cannot update design (body content, not YAML)
When I run "ticket update test-0001 --design=text"
Then the command should fail
And the output should contain "markdown body"

Scenario: Cannot update acceptance (body content, not YAML)
When I run "ticket update test-0001 --acceptance=text"
Then the command should fail
And the output should contain "markdown body"

Scenario: Priority must be 0-4
When I run "ticket update test-0001 --priority=99"
Then the command should fail
And the output should contain "priority must be 0-4"

Scenario: Priority accepts valid range
When I run "ticket update test-0001 --priority=0"
Then the command should succeed
And ticket "test-0001" should have field "priority" with value "0"

Scenario: Type must be one of known values
When I run "ticket update test-0001 --type=banana"
Then the command should fail
And the output should contain "must be one of: bug, feature, task, epic, chore"

Scenario: Parent must reference existing ticket
When I run "ticket update test-0001 --parent=nonexistent"
Then the command should fail
And the output should contain "parent ticket 'nonexistent' not found"

Scenario: Parent can reference an existing ticket (positive path)
Given a ticket exists with ID "test-0002" and title "Parent ticket"
When I run "ticket update test-0001 --parent=test-0002"
Then the command should succeed
And ticket "test-0001" should have field "parent" with value "test-0002"

Scenario: Parent can be unset with empty value
When I run "ticket update test-0001 --parent="
Then the command should succeed

Scenario: Invalid field name syntax is rejected (regex injection guard)
When I run "ticket update test-0001 '--foo.bar=baz'"
Then the command should fail
And the output should contain "invalid field name"

Scenario: Field-name validation precedes JSON validation (ordering)
When I run "ticket update test-0001 '--foo.bar=[1,2,3]'"
Then the command should fail
And the output should contain "invalid field name"

Scenario: tags array items must not contain commas (reader limitation)
When I run "ticket update test-0001 '--tags=[\"urgent, internal\"]'"
Then the command should fail
And the output should contain "must not contain commas"

Scenario: Comma-in-tag rejection error names the reason (not generic JSON error)
When I run "ticket update test-0001 '--tags=[\"a, b\"]'"
Then the command should fail
And the output should contain "must not contain commas"

Scenario: Comma-in-value is allowed for non-tag freeform fields (release_notes)
When I run "ticket update test-0001 '--release_notes=[\"First, with comma.\", \"Second.\"]'"
Then the command should succeed
And ticket "test-0001" should have field "release_notes" with value "[\"First, with comma.\", \"Second.\"]"

Scenario: Custom field names are allowed (hybrid policy, freeform category)
When I run "ticket update test-0001 --sprint=42"
Then the command should succeed
And ticket "test-0001" should have field "sprint" with value "42"
4 changes: 2 additions & 2 deletions plugins/ticket-ls
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@ FNR==1 {
in_front && /^id:/ { id = $2 }
in_front && /^status:/ { status = $2 }
in_front && /^assignee:/ { assignee = $2 }
in_front && /^tags:/ { tags = $2; gsub(/[\[\] ]/, "", tags) }
in_front && /^tags:/ { tags = $2; gsub(/[\[\] "]/, "", tags) }
in_front && /^deps:/ {
deps = $2
gsub(/[\[\] ]/, "", deps)
gsub(/[\[\] "]/, "", deps)
}
!in_front && /^# / && title == "" { title = substr($0, 3) }
END { if (prev_file) emit() }
Expand Down
22 changes: 14 additions & 8 deletions plugins/ticket-query
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,21 @@ function emit() {
val = field_vals[i]
# Handle arrays
if (val ~ /^\[.*\]$/) {
gsub(/^\[|\]$/, "", val)
n = split(val, items, ", *")
printf "\"%s\":[", key
for (j = 1; j <= n; j++) {
if (j > 1) printf ","
gsub(/^ +| +$/, "", items[j])
if (items[j] != "") printf "\"%s\"", items[j]
if (val ~ /^\[[[:space:]]*"/) {
# New quoted form — already valid JSON, emit verbatim.
printf "\"%s\":%s", key, val
} else {
# Legacy unquoted CSV — split on comma.
gsub(/^\[|\]$/, "", val)
n = split(val, items, ", *")
printf "\"%s\":[", key
for (j = 1; j <= n; j++) {
if (j > 1) printf ","
gsub(/^ +| +$/, "", items[j])
if (items[j] != "") printf "\"%s\"", items[j]
}
printf "]"
}
printf "]"
} else {
printf "\"%s\":\"%s\"", key, val
}
Expand Down
Loading