diff --git a/CHANGELOG.md b/CHANGELOG.md index f25558f..1855ece 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,15 @@ ## [Unreleased] +### Fixed +- `update_yaml_field` now works on macOS/BSD (awk instead of GNU-specific sed for field insertion) + ### Changed - Extracted `edit`, `ls`, `query`, and `migrate-beads` commands to plugins (ticket-extras) ### Added +- `update` command for non-interactive YAML field updates (`tk update --field=value`) +- JSON array syntax support in update command (requires jq): `--tags='["a", "b"]'` - Plugin system: executables named `tk-` or `ticket-` 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 diff --git a/README.md b/README.md index 2d7017e..99346cb 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,8 @@ Commands: close Set status to closed reopen Set status to open status Update status (open|in_progress|closed) + update --field=value Update YAML field(s) non-interactively + Arrays: --field='["a", "b"]' (JSON syntax) dep Add dependency (id depends on dep-id) dep tree [--full] Show dependency tree (--full disables dedup) dep cycle Find dependency cycles in open tickets diff --git a/features/ticket_update.feature b/features/ticket_update.feature new file mode 100644 index 0000000..bc413f7 --- /dev/null +++ b/features/ticket_update.feature @@ -0,0 +1,176 @@ +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: 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: Reject array items with embedded commas (ecosystem 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-item error is specific, not generic (jq error coupling) + 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: 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" diff --git a/ticket b/ticket index 0aea72c..7736f4d 100755 --- a/ticket +++ b/ticket @@ -139,19 +139,112 @@ yaml_field() { } # Update YAML field +# +# Both branches use awk with -v to pass field/value as variables. This avoids +# sed's s/// metacharacter hazards (&, /, \) that would corrupt values or +# break the separator. First-match-wins: for well-formed frontmatter with a +# single definition per field, behavior is identical to a global replace. +# For accidentally duplicated keys, the canonical first entry stays +# authoritative and the duplicate remains visible as an artifact rather than +# being silently mirrored. update_yaml_field() { local file="$1" local field="$2" local value="$3" + local tmp="${file}.tmp.$$" if _grep -q "^${field}:" "$file"; then - _sed_i "$file" "s/^${field}:.*/${field}: ${value}/" + # Replace existing field — exit 2 if no match found, to prevent + # silently writing an unchanged file (defense against encoding/ + # race issues that slip past the _grep gate). + awk -v field="$field" -v value="$value" ' + !done && $0 ~ "^" field ":" { print field ": " value; done=1; next } + { print } + END { if (!done) exit 2 } + ' "$file" > "$tmp" && mv "$tmp" "$file" else # Insert after first --- (beginning of frontmatter) - _sed_i "$file" "0,/^---$/ { /^---$/a\\ -${field}: ${value} -}" + awk -v field="$field" -v value="$value" ' + NR==1 && /^---$/ { print; print field ": " value; done=1; next } + { print } + END { if (!done) exit 2 } + ' "$file" > "$tmp" && mv "$tmp" "$file" + fi +} + +# Classify and validate a proposed field update. Categories: +# immutable — id, created: breaks file-name coupling or audit trail +# routed — status, deps, links: has dedicated command with validation +# body-backed — title, description, design, acceptance: live in markdown +# body (not YAML frontmatter); writing them to YAML +# creates desync (cmd_create emits them as body sections) +# validated — priority, parent, type: YAML-backed, value must match rules +# freeform — anything else, as long as the key name is parser-safe +# +# Returns 0 if the update is allowed, 1 with a clear error otherwise. +validate_field_update() { + local field="$1" + local value="$2" + + # Guard: field-name syntax — prevents regex-injection into update_yaml_field + # (field is interpolated into awk's regex "^" field ":") + if [[ ! "$field" =~ ^[A-Za-z][A-Za-z0-9_-]*$ ]]; then + echo "Error: invalid field name '$field' (allowed: [A-Za-z][A-Za-z0-9_-]*)" >&2 + return 1 fi + + case "$field" in + # immutable + id|created) + echo "Error: field '$field' is immutable (would break file-name coupling / audit trail)" >&2 + return 1 + ;; + # routed to dedicated commands + status) + echo "Error: use 'tk status ' to update status (validates allowed values)" >&2 + return 1 + ;; + deps) + echo "Error: use 'tk dep ' to add dependencies (validates existence)" >&2 + return 1 + ;; + links) + echo "Error: use 'tk link ' to link tickets (maintains symmetric backlinks)" >&2 + return 1 + ;; + # body-backed (not YAML frontmatter) + title|description|design|acceptance) + echo "Error: '$field' is stored in the ticket's markdown body, not frontmatter. Use 'tk edit ' to modify body sections" >&2 + return 1 + ;; + # validated + priority) + if [[ ! "$value" =~ ^[0-4]$ ]]; then + echo "Error: priority must be 0-4 (got '$value')" >&2 + return 1 + fi + ;; + type) + case "$value" in + bug|feature|task|epic|chore) ;; + *) + echo "Error: type must be one of: bug, feature, task, epic, chore (got '$value')" >&2 + return 1 + ;; + esac + ;; + parent) + # Parent must be an existing ticket (empty value allowed to unset) + if [[ -n "$value" ]]; then + if ! ticket_path "$value" >/dev/null 2>&1; then + echo "Error: parent ticket '$value' not found" >&2 + return 1 + fi + fi + ;; + # freeform custom fields — pass through + esac + return 0 } cmd_create() { @@ -1280,6 +1373,8 @@ Commands: close Set status to closed reopen Set status to open status Update status (open|in_progress|closed) + update --field=value Update YAML field(s) non-interactively + Arrays: --field='["a", "b"]' (JSON syntax) dep Add dependency (id depends on dep-id) dep tree [--full] Show dependency tree (--full disables dedup) dep cycle Find dependency cycles in open tickets @@ -1317,6 +1412,96 @@ Supports partial ID matching (e.g., '$cmd show 5c4' matches 'nw-5c46') EOF } +cmd_update() { + if [[ $# -lt 2 ]]; then + echo "Usage: $(basename "$0") update --field=value [--field2=value2 ...]" >&2 + return 1 + fi + + local file + file=$(ticket_path "$1") || return 1 + shift + + local updated=0 + while [[ $# -gt 0 ]]; do + case "$1" in + --*=*) + local arg="${1#--}" + local field="${arg%%=*}" + local value="${arg#*=}" + + # Validate field-name syntax, classify category, validate value. + # Runs BEFORE JSON array handling so that "invalid field name" + # errors take priority over "invalid JSON" errors — specific + # diagnosis beats generic. + validate_field_update "$field" "$value" || return 1 + + # Handle JSON array syntax -> YAML flow array + if [[ "$value" =~ ^\[.*\]$ ]]; then + if command -v jq &>/dev/null; then + # jq exits non-zero on invalid JSON. It also exits + # non-zero via error() if any array item contains a + # comma — comma-in-item would corrupt round-trip + # through consumers that split on ", *" (ticket-query, + # ticket-ls, internal awk splits). + local yaml_val + yaml_val=$(echo "$value" | jq -er ' + if type == "array" then + if any(.[] | tostring; test(",")) then + error("array item contains comma") + else + "[" + (map(tostring) | join(", ")) + "]" + end + else + . + end + ' 2>&1) || { + if echo "$yaml_val" | grep -q "array item contains comma"; then + echo "Error: array items for '$field' must not contain commas. The ticket ecosystem stores arrays as comma-separated values and has no quote-aware parsing in consumers (ticket-query, ticket-ls). Use dashes or underscores instead." >&2 + else + echo "Error: invalid JSON for $field" >&2 + fi + return 1 + } + value="$yaml_val" + else + # This branch is exercised only when jq is absent; + # manual verification required on a jq-less system. + # Still guard the contract: reject items that contain + # commas, since downstream consumers split on commas. + local inner="${value#[}" + inner="${inner%]}" + # Require 1+ non-quote chars on each side of the comma: + # that anchors the comma INSIDE a quoted string and + # excludes the separator case '","' between items. + if echo "$inner" | grep -qE '"[^"]+,[^"]+"'; then + echo "Error: array items for '$field' must not contain commas (ecosystem limitation)" >&2 + return 1 + fi + # Value passes through as-is (user's responsibility beyond comma check) + fi + fi + + update_yaml_field "$file" "$field" "$value" + updated=$((updated + 1)) + shift + ;; + *) + echo "Error: unknown argument '$1'" >&2 + echo "Usage: $(basename "$0") update --field=value" >&2 + return 1 + ;; + esac + done + + if [[ $updated -gt 0 ]]; then + echo "Updated $updated field(s) on $(basename "$file" .md)" + else + echo "No fields specified" >&2 + return 1 + fi +} + # Main dispatch # Handle 'super' to bypass plugins @@ -1373,6 +1558,7 @@ case "${1:-help}" in blocked) shift; cmd_blocked "$@" ;; closed) shift; cmd_closed "$@" ;; show) shift; cmd_show "$@" ;; + update) shift; cmd_update "$@" ;; add-note) shift; cmd_add_note "$@" ;; help|--help|-h) cmd_help ;; *)