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/steps/ticket_steps.py b/features/steps/ticket_steps.py index 54d3ab3..9069b71 100644 --- a/features/steps/ticket_steps.py +++ b/features/steps/ticket_steps.py @@ -27,7 +27,7 @@ def get_ticket_script(context): return str(Path(context.project_dir) / 'ticket') -def create_ticket(context, ticket_id, title, priority=2, parent=None): +def create_ticket(context, ticket_id, title, priority=2, parent=None, type="task"): """Helper to create a ticket file.""" tickets_dir = Path(context.test_dir) / '.tickets' tickets_dir.mkdir(parents=True, exist_ok=True) @@ -39,7 +39,7 @@ def create_ticket(context, ticket_id, title, priority=2, parent=None): deps: [] links: [] created: 2024-01-01T00:00:00Z -type: task +type: {type} priority: {priority} ''' if parent: @@ -86,6 +86,12 @@ def step_ticket_exists_with_priority(context, ticket_id, title, priority): create_ticket(context, ticket_id, title, priority=int(priority)) +@given(r'a ticket exists with ID "(?P[^"]+)" and title "(?P[^"]+)" with type "(?P<type>[^"]+)"') +def step_ticket_exists_with_type(context, ticket_id, title, type): + """Create a ticket with given ID, title, and type.""" + create_ticket(context, ticket_id, title, type=type) + + @given(r'a ticket exists with ID "(?P<ticket_id>[^"]+)" and title "(?P<title>[^"]+)" with parent "(?P<parent_id>[^"]+)"') def step_ticket_exists_with_parent(context, ticket_id, title, parent_id): """Create a ticket with given ID, title, and parent.""" @@ -108,6 +114,15 @@ def step_ticket_has_status(context, ticket_id, status): ticket_path.write_text(content) +@given(r'ticket "(?P<ticket_id>[^"]+)" has priority (?P<priority>\d+)') +def step_ticket_has_priority(context, ticket_id, priority): + """Set ticket priority.""" + ticket_path = Path(context.test_dir) / '.tickets' / f'{ticket_id}.md' + content = ticket_path.read_text() + content = re.sub(r'^priority: \d+', f'priority: {priority}', content, flags=re.MULTILINE) + ticket_path.write_text(content) + + @given(r'ticket "(?P<ticket_id>[^"]+)" depends on "(?P<dep_id>[^"]+)"') def step_ticket_depends_on(context, ticket_id, dep_id): """Add dependency to ticket.""" @@ -343,6 +358,14 @@ def step_command_fail(context): f"Command succeeded but was expected to fail\nstdout: {context.stdout}" +@then(r'the command should exit with code (?P<code>\d+)') +def step_command_exit_code(context, code): + """Assert command exited with specific code.""" + expected = int(code) + assert context.returncode == expected, \ + f"Expected exit code {expected} but got {context.returncode}\nstdout: {context.stdout}\nstderr: {context.stderr}" + + @then(r'the output should be "(?P<expected>[^"]*)"') def step_output_equals(context, expected): """Assert output exactly matches expected string.""" diff --git a/features/ticket_listing.feature b/features/ticket_listing.feature index 7c78852..1dc838c 100644 --- a/features/ticket_listing.feature +++ b/features/ticket_listing.feature @@ -161,3 +161,88 @@ Feature: Ticket Listing When I run "ticket closed" Then the command should succeed And the output should not contain "done-0001" + + Scenario: List with type filter (long flag) + Given a ticket exists with ID "list-0001" and title "A bug" with type "bug" + And a ticket exists with ID "list-0002" and title "A task" with type "task" + When I run "ticket ls --type=bug" + Then the command should succeed + And the output should contain "list-0001" + And the output should not contain "list-0002" + + Scenario: List with type filter (short flag) + Given a ticket exists with ID "list-0001" and title "A bug" with type "bug" + And a ticket exists with ID "list-0002" and title "A task" with type "task" + When I run "ticket ls -t bug" + Then the command should succeed + And the output should contain "list-0001" + And the output should not contain "list-0002" + + Scenario: List with priority filter (long flag) + Given a ticket exists with ID "list-0001" and title "High" with priority 1 + And a ticket exists with ID "list-0002" and title "Low" with priority 3 + When I run "ticket ls --priority=1" + Then the command should succeed + And the output should contain "list-0001" + And the output should not contain "list-0002" + + Scenario: List with priority filter (short flag) + Given a ticket exists with ID "list-0001" and title "High" with priority 1 + And a ticket exists with ID "list-0002" and title "Low" with priority 3 + When I run "ticket ls -p 1" + Then the command should succeed + And the output should contain "list-0001" + And the output should not contain "list-0002" + + Scenario: List with combined type and priority filters + Given a ticket exists with ID "list-0001" and title "Urgent bug" with type "bug" + And ticket "list-0001" has priority 1 + And a ticket exists with ID "list-0002" and title "Minor bug" with type "bug" + And ticket "list-0002" has priority 3 + And a ticket exists with ID "list-0003" and title "Urgent task" with type "task" + And ticket "list-0003" has priority 1 + When I run "ticket ls --type=bug --priority=1" + Then the command should succeed + And the output should contain "list-0001" + And the output should not contain "list-0002" + And the output should not contain "list-0003" + + Scenario: List rejects invalid type value + Given a ticket exists with ID "list-0001" and title "Any" with type "bug" + When I run "ticket ls --type=xyz" + Then the command should fail + And the output should contain "invalid --type value" + And the output should contain "xyz" + + Scenario: List rejects invalid priority value + Given a ticket exists with ID "list-0001" and title "Any" with priority 1 + When I run "ticket ls --priority=9" + Then the command should fail + And the output should contain "invalid --priority value" + And the output should contain "9" + + Scenario: List rejects unknown long flag + Given a ticket exists with ID "list-0001" and title "Any" + When I run "ticket ls --unknown=value" + Then the command should fail + And the output should contain "unknown argument" + And the output should contain "--unknown=value" + + Scenario: List rejects unknown short flag + Given a ticket exists with ID "list-0001" and title "Any" + When I run "ticket ls -z" + Then the command should fail + And the output should contain "unknown argument" + And the output should contain "-z" + + Scenario: List rejects unknown positional argument + Given a ticket exists with ID "list-0001" and title "Any" + When I run "ticket ls extraarg" + Then the command should fail + And the output should contain "unknown argument" + And the output should contain "extraarg" + + Scenario: List exits with code 2 on unknown argument + Given a ticket exists with ID "list-0001" and title "Any" + When I run "ticket ls --unknown" + Then the command should exit with code 2 diff --git a/features/ticket_update.feature b/features/ticket_update.feature new file mode 100644 index 0000000..fcca897 --- /dev/null +++ b/features/ticket_update.feature @@ -0,0 +1,58 @@ +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" diff --git a/plugins/ticket-ls b/plugins/ticket-ls index 5bb44b1..8dc7f0b 100755 --- a/plugins/ticket-ls +++ b/plugins/ticket-ls @@ -3,7 +3,7 @@ # tk-plugin-version: 1.0.0 set -euo pipefail -status_filter="" assignee_filter="" tag_filter="" +status_filter="" assignee_filter="" tag_filter="" type_filter="" priority_filter="" while [[ $# -gt 0 ]]; do case "$1" in --status=*) status_filter="${1#--status=}"; shift ;; @@ -11,15 +11,28 @@ while [[ $# -gt 0 ]]; do --assignee=*) assignee_filter="${1#--assignee=}"; shift ;; -T) tag_filter="$2"; shift 2 ;; --tag=*) tag_filter="${1#--tag=}"; shift ;; - *) shift ;; + -t) type_filter="$2"; shift 2 ;; + --type=*) type_filter="${1#--type=}"; shift ;; + -p) priority_filter="$2"; shift 2 ;; + --priority=*) priority_filter="${1#--priority=}"; shift ;; + *) echo "ticket list: unknown argument '$1'" >&2; exit 2 ;; esac done -awk -v status_filter="$status_filter" -v assignee_filter="$assignee_filter" -v tag_filter="$tag_filter" ' +if [[ -n "$type_filter" && ! "$type_filter" =~ ^(bug|feature|task|epic|chore)$ ]]; then + echo "ticket list: invalid --type value '$type_filter' (expected: bug|feature|task|epic|chore)" >&2 + exit 2 +fi +if [[ -n "$priority_filter" && ! "$priority_filter" =~ ^[0-4]$ ]]; then + echo "ticket list: invalid --priority value '$priority_filter' (expected: 0..4)" >&2 + exit 2 +fi + +awk -v status_filter="$status_filter" -v assignee_filter="$assignee_filter" -v tag_filter="$tag_filter" -v type_filter="$type_filter" -v priority_filter="$priority_filter" ' BEGIN { FS=": "; in_front=0 } FNR==1 { if (prev_file) emit() - id=""; status=""; title=""; deps=""; assignee=""; tags=""; in_front=0 + id=""; status=""; title=""; deps=""; assignee=""; tags=""; type=""; priority=""; in_front=0 prev_file=FILENAME } /^---$/ { in_front = !in_front; next } @@ -27,6 +40,8 @@ in_front && /^id:/ { id = $2 } in_front && /^status:/ { status = $2 } in_front && /^assignee:/ { assignee = $2 } in_front && /^tags:/ { tags = $2; gsub(/[\[\] ]/, "", tags) } +in_front && /^type:/ { type = $2 } +in_front && /^priority:/ { priority = $2 } in_front && /^deps:/ { deps = $2 gsub(/[\[\] ]/, "", deps) @@ -39,7 +54,7 @@ function has_tag(tags_str, tag, i, n, arr) { return 0 } function emit() { - if (id != "" && (status_filter == "" || status == status_filter) && (assignee_filter == "" || assignee == assignee_filter) && (tag_filter == "" || has_tag(tags, tag_filter))) { + if (id != "" && (status_filter == "" || status == status_filter) && (assignee_filter == "" || assignee == assignee_filter) && (tag_filter == "" || has_tag(tags, tag_filter)) && (type_filter == "" || type == type_filter) && (priority_filter == "" || priority == priority_filter)) { deps_display = (deps != "") ? "[" deps "]" : "[]" gsub(/,/, ", ", deps_display) dep_str = (deps_display != "[]") ? " <- " deps_display : "" diff --git a/ticket b/ticket index 0aea72c..ba12c80 100755 --- a/ticket +++ b/ticket @@ -148,9 +148,12 @@ update_yaml_field() { _sed_i "$file" "s/^${field}:.*/${field}: ${value}/" else # Insert after first --- (beginning of frontmatter) - _sed_i "$file" "0,/^---$/ { /^---$/a\\ -${field}: ${value} -}" + # Using awk for BSD/GNU portability (sed 0,/pat/ and a\ differ) + local tmp="${file}.tmp.$$" + awk -v field="$field" -v value="$value" ' + NR==1 && /^---$/ { print; print field ": " value; next } + { print } + ' "$file" > "$tmp" && mv "$tmp" "$file" fi } @@ -1280,6 +1283,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 @@ -1317,6 +1322,63 @@ Supports partial ID matching (e.g., '$cmd show 5c4' matches 'nw-5c46') EOF } +cmd_update() { + if [[ $# -lt 2 ]]; then + echo "Usage: $(basename "$0") update <id> --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#*=}" + + # Handle JSON array syntax -> YAML flow array + if [[ "$value" =~ ^\[.*\]$ ]]; then + if command -v jq &>/dev/null; then + local yaml_val + yaml_val=$(echo "$value" | jq -r ' + if type == "array" then + "[" + (map(tostring) | join(", ")) + "]" + else + . + end + ' 2>/dev/null) || { + echo "Error: invalid JSON for $field" >&2 + return 1 + } + value="$yaml_val" + fi + # If no jq, pass value as-is (user's responsibility) + fi + + update_yaml_field "$file" "$field" "$value" + ((updated++)) + shift + ;; + *) + echo "Error: unknown argument '$1'" >&2 + echo "Usage: $(basename "$0") update <id> --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 +1435,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 ;; *)