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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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
27 changes: 25 additions & 2 deletions features/steps/ticket_steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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<ticket_id>[^"]+)" and title "(?P<title>[^"]+)" 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."""
Expand All @@ -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."""
Expand Down Expand Up @@ -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."""
Expand Down
85 changes: 85 additions & 0 deletions features/ticket_listing.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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
58 changes: 58 additions & 0 deletions features/ticket_update.feature
Original file line number Diff line number Diff line change
@@ -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"
25 changes: 20 additions & 5 deletions plugins/ticket-ls
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,45 @@
# 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 ;;
-a) assignee_filter="$2"; shift 2 ;;
--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 }
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)
Expand All @@ -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 : ""
Expand Down
69 changes: 66 additions & 3 deletions ticket
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 ;;
*)
Expand Down