From acf31adeb75064170786eb5a735f6d5cf6f7def3 Mon Sep 17 00:00:00 2001 From: Rich Snapp Date: Sun, 1 Feb 2026 00:00:25 +0000 Subject: [PATCH] add `tk tree` command --- CHANGELOG.md | 8 ++ README.md | 1 + features/steps/ticket_steps.py | 37 ++++- features/ticket_tree.feature | 158 +++++++++++++++++++++ ticket | 247 +++++++++++++++++++++++++++++++++ 5 files changed, 445 insertions(+), 6 deletions(-) create mode 100644 features/ticket_tree.feature diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b62c46..80ccab3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [Unreleased] + +### Added +- `tree` command to display parent-child ticket hierarchy with box-drawing characters + - Supports `--status`, `-a` (assignee), and `-T` (tag) filters + - Ancestor tickets are preserved when filtering to maintain tree structure + - Children sorted by subtree depth (shallow first), then by ID + ## [0.3.1] - 2026-01-28 ### Added diff --git a/README.md b/README.md index 1b5abaf..1e2688c 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ Commands: close Set status to closed reopen Set status to open status Update status (open|in_progress|closed) + tree [id] [--status=X] [-a X] [-T X] Show parent-child ticket hierarchy 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 276eb27..fb53740 100644 --- a/features/steps/ticket_steps.py +++ b/features/steps/ticket_steps.py @@ -166,6 +166,31 @@ def step_ticket_linked_to(context, ticket_id, link_id): link_path.write_text(content) +@given(r'ticket "(?P[^"]+)" has assignee "(?P[^"]+)"') +def step_ticket_has_assignee(context, ticket_id, assignee): + """Set ticket assignee.""" + ticket_path = Path(context.test_dir) / '.tickets' / f'{ticket_id}.md' + content = ticket_path.read_text() + if re.search(r'^assignee:', content, re.MULTILINE): + content = re.sub(r'^assignee:.*$', f'assignee: {assignee}', content, flags=re.MULTILINE) + else: + content = content.replace('---\n# ', f'assignee: {assignee}\n---\n# ', 1) + ticket_path.write_text(content) + + +@given(r'ticket "(?P[^"]+)" has tags "(?P[^"]+)"') +def step_ticket_has_tags(context, ticket_id, tags): + """Set ticket tags.""" + ticket_path = Path(context.test_dir) / '.tickets' / f'{ticket_id}.md' + content = ticket_path.read_text() + tags_yaml = f'tags: [{tags}]' + if re.search(r'^tags:', content, re.MULTILINE): + content = re.sub(r'^tags:.*$', tags_yaml, content, flags=re.MULTILINE) + else: + content = content.replace('---\n# ', f'{tags_yaml}\n---\n# ', 1) + ticket_path.write_text(content) + + @given(r'ticket "(?P[^"]+)" has a notes section') def step_ticket_has_notes(context, ticket_id): """Ensure ticket has a notes section.""" @@ -591,21 +616,21 @@ def step_jsonl_deps_is_array(context): raise AssertionError("No JSONL line with deps field found") -@then(r'the dep tree output should have (?P[^\s]+) before (?P[^\s]+)') -def step_dep_tree_order(context, first_id, second_id): - """Assert that first_id appears before second_id in dep tree output.""" +@then(r'the (?:dep )?tree output should have (?P[^\s]+) before (?P[^\s]+)') +def step_tree_output_order(context, first_id, second_id): + """Assert that first_id appears before second_id in tree output.""" output = context.stdout lines = output.split('\n') - + first_line = -1 second_line = -1 - + for i, line in enumerate(lines): if first_id in line: first_line = i if second_id in line: second_line = i - + assert first_line != -1, f"'{first_id}' not found in output:\n{output}" assert second_line != -1, f"'{second_id}' not found in output:\n{output}" assert first_line < second_line, \ diff --git a/features/ticket_tree.feature b/features/ticket_tree.feature new file mode 100644 index 0000000..3b76441 --- /dev/null +++ b/features/ticket_tree.feature @@ -0,0 +1,158 @@ +Feature: Ticket Tree (Parent-Child Hierarchy) + As a user + I want to view the parent-child ticket hierarchy + So that I can understand how tickets are organized + + Background: + Given a clean tickets directory + + Scenario: Tree with no args shows root tickets and children + Given a ticket exists with ID "proj-0001" and title "Epic Auth" + And a ticket exists with ID "proj-0002" and title "Login page" with parent "proj-0001" + And a ticket exists with ID "proj-0003" and title "Standalone task" + When I run "ticket tree" + Then the command should succeed + And the output should contain "proj-0001" + And the output should contain "proj-0002" + And the output should contain "proj-0003" + + Scenario: Tree with specific ID shows subtree + Given a ticket exists with ID "proj-0001" and title "Epic Auth" + And a ticket exists with ID "proj-0002" and title "Login page" with parent "proj-0001" + And a ticket exists with ID "proj-0003" and title "Standalone task" + When I run "ticket tree proj-0001" + Then the command should succeed + And the output should contain "proj-0001" + And the output should contain "proj-0002" + And the output should not contain "proj-0003" + + Scenario: Tree uses box-drawing characters + Given a ticket exists with ID "proj-0001" and title "Epic Auth" + And a ticket exists with ID "proj-0002" and title "Login page" with parent "proj-0001" + When I run "ticket tree proj-0001" + Then the command should succeed + And the output should match box-drawing tree format + + Scenario: Tree shows status and title + Given a ticket exists with ID "proj-0001" and title "Epic Auth" + And a ticket exists with ID "proj-0002" and title "Login page" with parent "proj-0001" + And ticket "proj-0002" has status "in_progress" + When I run "ticket tree" + Then the command should succeed + And the output should contain "[open]" + And the output should contain "[in_progress]" + And the output should contain "Epic Auth" + And the output should contain "Login page" + + Scenario: Tree handles deep nesting (grandchildren) + Given a ticket exists with ID "proj-0001" and title "Epic" + And a ticket exists with ID "proj-0002" and title "Feature" with parent "proj-0001" + And a ticket exists with ID "proj-0003" and title "Task" with parent "proj-0002" + When I run "ticket tree" + Then the command should succeed + And the output should contain "proj-0001" + And the output should contain "proj-0002" + And the output should contain "proj-0003" + And the tree output should have proj-0001 before proj-0002 + And the tree output should have proj-0002 before proj-0003 + + Scenario: Tree handles leaf tickets (no children) + Given a ticket exists with ID "proj-0001" and title "Leaf task" + When I run "ticket tree proj-0001" + Then the command should succeed + And the output should contain "proj-0001" + And the output should contain "Leaf task" + And the output line count should be 1 + + Scenario: Tree sorts children by subtree depth then ID + Given a ticket exists with ID "proj-0001" and title "Root" + And a ticket exists with ID "proj-0005" and title "Child B shallow" with parent "proj-0001" + And a ticket exists with ID "proj-0003" and title "Child A shallow" with parent "proj-0001" + And a ticket exists with ID "proj-0004" and title "Child C deep" with parent "proj-0001" + And a ticket exists with ID "proj-0006" and title "Grandchild" with parent "proj-0004" + When I run "ticket tree proj-0001" + Then the command should succeed + And the tree output should have proj-0003 before proj-0005 + And the tree output should have proj-0005 before proj-0004 + And the tree output should have proj-0004 before proj-0006 + + Scenario: Tree sorts children by ID when same depth + Given a ticket exists with ID "proj-0001" and title "Root" + And a ticket exists with ID "proj-0005" and title "Child E" with parent "proj-0001" + And a ticket exists with ID "proj-0002" and title "Child B" with parent "proj-0001" + And a ticket exists with ID "proj-0004" and title "Child D" with parent "proj-0001" + And a ticket exists with ID "proj-0003" and title "Child C" with parent "proj-0001" + When I run "ticket tree proj-0001" + Then the command should succeed + And the tree output should have proj-0002 before proj-0003 + And the tree output should have proj-0003 before proj-0004 + And the tree output should have proj-0004 before proj-0005 + + Scenario: Tree with status filter shows matching tickets and ancestors + Given a ticket exists with ID "proj-0001" and title "Epic Auth" + And a ticket exists with ID "proj-0002" and title "Login page" with parent "proj-0001" + And a ticket exists with ID "proj-0003" and title "Signup page" with parent "proj-0001" + And ticket "proj-0001" has status "closed" + And ticket "proj-0002" has status "open" + And ticket "proj-0003" has status "closed" + When I run "ticket tree --status=open" + Then the command should succeed + And the output should contain "proj-0001" + And the output should contain "proj-0002" + And the output should not contain "proj-0003" + + Scenario: Tree with assignee filter + Given a ticket exists with ID "proj-0001" and title "Epic Auth" + And a ticket exists with ID "proj-0002" and title "Login page" with parent "proj-0001" + And a ticket exists with ID "proj-0003" and title "Signup page" with parent "proj-0001" + And ticket "proj-0002" has assignee "alice" + And ticket "proj-0003" has assignee "bob" + When I run "ticket tree -a alice" + Then the command should succeed + And the output should contain "proj-0001" + And the output should contain "proj-0002" + And the output should not contain "proj-0003" + + Scenario: Tree with tag filter + Given a ticket exists with ID "proj-0001" and title "Epic Auth" + And a ticket exists with ID "proj-0002" and title "Login page" with parent "proj-0001" + And a ticket exists with ID "proj-0003" and title "Signup page" with parent "proj-0001" + And ticket "proj-0002" has tags "frontend,ui" + And ticket "proj-0003" has tags "backend" + When I run "ticket tree -T frontend" + Then the command should succeed + And the output should contain "proj-0001" + And the output should contain "proj-0002" + And the output should not contain "proj-0003" + + Scenario: Tree with no matching tickets produces no output + Given a ticket exists with ID "proj-0001" and title "Epic Auth" + And ticket "proj-0001" has status "closed" + When I run "ticket tree --status=in_progress" + Then the command should succeed + And the output should be empty + + Scenario: Tree supports partial ID matching + Given a ticket exists with ID "proj-0001" and title "Epic Auth" + And a ticket exists with ID "proj-0002" and title "Login page" with parent "proj-0001" + When I run "ticket tree 0001" + Then the command should succeed + And the output should contain "proj-0001" + And the output should contain "proj-0002" + + Scenario: Tree with non-existent ID fails + Given a ticket exists with ID "proj-0001" and title "Epic Auth" + When I run "ticket tree nonexistent" + Then the command should fail + And the output should contain "Error: ticket nonexistent not found" + + Scenario: Tree multiple roots sorted by subtree depth then ID + Given a ticket exists with ID "proj-0003" and title "Root C (deep)" + And a ticket exists with ID "proj-0001" and title "Root A (shallow)" + And a ticket exists with ID "proj-0002" and title "Root B (shallow)" + And a ticket exists with ID "proj-0004" and title "Child of C" with parent "proj-0003" + When I run "ticket tree" + Then the command should succeed + And the tree output should have proj-0001 before proj-0002 + And the tree output should have proj-0002 before proj-0003 + And the tree output should have proj-0003 before proj-0004 diff --git a/ticket b/ticket index 452adda..242c3c1 100755 --- a/ticket +++ b/ticket @@ -286,6 +286,251 @@ cmd_reopen() { cmd_status "$1" "open" } +cmd_tree() { + local root_id="" status_filter="" assignee_filter="" tag_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 ;; + -*) echo "Unknown option: $1" >&2; return 1 ;; + *) root_id="$1"; shift ;; + esac + done + + local _files + _files=$(find "$TICKETS_DIR" -maxdepth 1 -name '*.md' 2>/dev/null) + if [[ -z "$_files" ]]; then + if [[ -n "$root_id" ]]; then + echo "Error: ticket $root_id not found" >&2 + return 1 + fi + return 0 + fi + + awk -v root_pattern="$root_id" -v status_filter="$status_filter" \ + -v assignee_filter="$assignee_filter" -v tag_filter="$tag_filter" ' + BEGIN { FS=": "; in_front=0 } + FNR==1 { + if (prev_file) store() + id=""; status=""; title=""; parent=""; assignee=""; tags=""; in_front=0 + prev_file=FILENAME + } + /^---$/ { in_front = !in_front; next } + in_front && /^id:/ { id = $2 } + in_front && /^status:/ { status = $2 } + in_front && /^parent:/ { parent = $2 } + in_front && /^assignee:/ { assignee = $2 } + in_front && /^tags:/ { tags = $2; gsub(/[\[\] ]/, "", tags) } + !in_front && /^# / && title == "" { title = substr($0, 3) } + function store() { + if (id != "") { + statuses[id] = status + titles[id] = title + parent_of[id] = parent + assignees[id] = assignee + all_tags[id] = tags + ticket_count++ + ticket_ids[ticket_count] = id + } + } + + function has_tag(tags_str, tag, i, n, arr) { + n = split(tags_str, arr, ",") + for (i = 1; i <= n; i++) if (arr[i] == tag) return 1 + return 0 + } + + END { + if (prev_file) store() + + # Build children map from parent field + for (i = 1; i <= ticket_count; i++) { + id = ticket_ids[i] + p = parent_of[id] + if (p != "" && p in statuses) { + child_count[p]++ + child_list[p, child_count[p]] = id + } + } + + # Determine filter active + filtering = (status_filter != "" || assignee_filter != "" || tag_filter != "") + + # Mark tickets matching filter + for (i = 1; i <= ticket_count; i++) { + id = ticket_ids[i] + if (!filtering) { + matches[id] = 1 + } else { + ok = 1 + if (status_filter != "" && statuses[id] != status_filter) ok = 0 + if (assignee_filter != "" && assignees[id] != assignee_filter) ok = 0 + if (tag_filter != "" && !has_tag(all_tags[id], tag_filter)) ok = 0 + if (ok) matches[id] = 1 + } + } + + # Propagate upward: if a ticket matches, all ancestors are included + if (filtering) { + changed = 1 + while (changed) { + changed = 0 + for (i = 1; i <= ticket_count; i++) { + id = ticket_ids[i] + if (!(id in included) && (id in matches)) { + included[id] = 1 + # Walk up ancestors + cur = id + while (parent_of[cur] != "" && parent_of[cur] in statuses) { + cur = parent_of[cur] + if (cur in included) break + included[cur] = 1 + changed = 1 + } + } + } + if (!changed) break + } + # included = tickets to show; matches = tickets that actually match filter + } + + # Resolve root(s) + if (root_pattern != "") { + root = "" + for (id in statuses) { + if (index(id, root_pattern) > 0) { + if (root != "") { + print "Error: ambiguous ID " root_pattern > "/dev/stderr" + exit 1 + } + root = id + } + } + if (root == "") { + print "Error: ticket " root_pattern " not found" > "/dev/stderr" + exit 1 + } + root_count = 1 + roots[1] = root + } else { + # Collect all root tickets (no parent or parent not found) + root_count = 0 + for (i = 1; i <= ticket_count; i++) { + id = ticket_ids[i] + p = parent_of[id] + if (p == "" || !(p in statuses)) { + if (!filtering || (id in included)) { + root_count++ + roots[root_count] = id + } + } + } + } + + # Compute subtree depths (iterative post-order from each root) + for (r = 1; r <= root_count; r++) { + delete _stack; delete _path; delete _phase + _stack[1] = roots[r]; _path[1] = ":"; _phase[1] = 0 + _sp = 1 + while (_sp > 0) { + _id = _stack[_sp]; _p = _path[_sp]; _ph = _phase[_sp] + + if (!(_id in statuses) || index(_p, ":" _id ":") > 0) { _sp--; continue } + + if (_ph == 0) { + _stack[_sp]; _phase[_sp] = 1 + _np = _p _id ":" + for (i = child_count[_id]; i >= 1; i--) { + ch = child_list[_id, i] + if (ch != "" && !(ch in subtree_depth)) { + _sp++ + _stack[_sp] = ch + _path[_sp] = _np + _phase[_sp] = 0 + } + } + } else { + max_sub = 0 + for (i = 1; i <= child_count[_id]; i++) { + ch = child_list[_id, i] + if (ch in subtree_depth && subtree_depth[ch] + 1 > max_sub) { + max_sub = subtree_depth[ch] + 1 + } + } + subtree_depth[_id] = max_sub + _sp-- + } + } + } + + # Sort roots by subtree_depth then ID + for (i = 2; i <= root_count; i++) { + tmp = roots[i] + j = i - 1 + while (j >= 1 && (subtree_depth[roots[j]] > subtree_depth[tmp] || \ + (subtree_depth[roots[j]] == subtree_depth[tmp] && roots[j] > tmp))) { + roots[j + 1] = roots[j] + j-- + } + roots[j + 1] = tmp + } + + # Print tree for each root + for (r = 1; r <= root_count; r++) { + id = roots[r] + if (filtering && !(id in included)) continue + print id " [" statuses[id] "] " titles[id] + build_tree_children(id, "", ":" id ":") + } + } + + function build_tree_children(id, prefix, path, i, n, ch, arr, tmp, j) { + # Collect printable children + n = 0 + for (i = 1; i <= child_count[id]; i++) { + ch = child_list[id, i] + if (ch == "") continue + if (!(ch in statuses)) continue + if (index(path, ":" ch ":") > 0) continue + if (filtering && !(ch in included)) continue + n++ + arr[n] = ch + } + if (n == 0) return + + # Sort by subtree_depth, then by ticket ID (insertion sort) + for (i = 2; i <= n; i++) { + tmp = arr[i] + j = i - 1 + while (j >= 1 && (subtree_depth[arr[j]] > subtree_depth[tmp] || \ + (subtree_depth[arr[j]] == subtree_depth[tmp] && arr[j] > tmp))) { + arr[j + 1] = arr[j] + j-- + } + arr[j + 1] = tmp + } + + # Print each child + for (i = 1; i <= n; i++) { + ch = arr[i] + if (i == n) { + connector = "└── " + new_prefix = prefix " " + } else { + connector = "├── " + new_prefix = prefix "│ " + } + print prefix connector ch " [" statuses[ch] "] " titles[ch] + build_tree_children(ch, new_prefix, path ch ":") + } + } + ' "$TICKETS_DIR"/*.md +} + cmd_dep_tree() { local full_mode=0 local root_id="" @@ -1452,6 +1697,7 @@ Commands: close Set status to closed reopen Set status to open status Update status (open|in_progress|closed) + tree [id] [--status=X] [-a X] [-T X] Show parent-child ticket hierarchy 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 @@ -1486,6 +1732,7 @@ case "${1:-help}" in close) shift; cmd_close "$@" ;; reopen) shift; cmd_reopen "$@" ;; status) shift; cmd_status "$@" ;; + tree) shift; cmd_tree "$@" ;; dep) shift; cmd_dep "$@" ;; undep) shift; cmd_undep "$@" ;; link) shift; cmd_link "$@" ;;