diff --git a/CHANGELOG.md b/CHANGELOG.md index f25558fc..0eec3dc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## [Unreleased] +### Added +- Add `archive` support for compacting closed tickets while preserving their + metadata for dependency, relationship, listing, and display commands + +### Fixed +- Keep routine commands responsive on large stores by reading archived ticket + metadata from one compact index instead of opening every closed ticket +- Prevent `closed` from exiting with SIGPIPE when more than 100 ticket files + are present + ### Changed - Extracted `edit`, `ls`, `query`, and `migrate-beads` commands to plugins (ticket-extras) @@ -15,9 +25,10 @@ - CI scripts for publishing to Homebrew tap and AUR ### Plugins -- ticket-edit 1.0.0: Open ticket in $EDITOR (extracted from core) -- ticket-ls 1.0.0: List tickets with optional filters (extracted from core); `ticket-list` symlink for alias -- ticket-query 1.0.0: Output tickets as JSON, optionally filtered with jq (extracted from core) +- ticket-edit 1.0.1: Resolve archived IDs and require reopening before edits +- ticket-ls 1.1.0: List active and indexed archived tickets without reopening + every archived Markdown file; `ticket-list` remains an alias +- ticket-query 1.0.1: Include archived tickets in JSON output - ticket-migrate-beads 1.0.0: Import tickets from .beads/issues.jsonl (extracted from core) ## [0.3.2] - 2026-02-03 diff --git a/README.md b/README.md index 2d7017e5..4fbf3d92 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,8 @@ Commands: undep Remove dependency link [id...] Link tickets together (symmetric) unlink Remove link between tickets + archive [id...] Move closed tickets into the indexed archive + --reindex Rebuild archive metadata without moving tickets ls|list [--status=X] [-a X] [-T X] List tickets ready [-a X] [-T X] List open/in-progress tickets with deps resolved blocked [-a X] [-T X] List open/in-progress tickets with unresolved deps @@ -95,6 +97,22 @@ Searches parent directories for .tickets/ (override with TICKETS_DIR env var) Supports partial ID matching (e.g., 'tk show 5c4' matches 'nw-5c46') ``` +## Archiving closed tickets + +Large ticket stores can keep routine graph commands fast by moving closed +tickets into the indexed archive: + +```bash +tk archive # archive every closed ticket +tk archive abc-1234 # archive selected closed tickets +tk archive --reindex # rebuild metadata after resolving archive merges +``` + +Archived Markdown files remain available to `show`, `closed`, `ls`, `query`, +partial-ID lookup, and dependency graph commands. Reopening an archived ticket +moves it back into the active directory. Relationship edits require reopening +first so the compact archive index cannot become stale. + ## Plugins Executables named `tk-` or `ticket-` in your PATH are invoked automatically. This allows you to add custom commands or override built-in ones. diff --git a/features/steps/ticket_steps.py b/features/steps/ticket_steps.py index 54d3ab3f..4a1cbcac 100644 --- a/features/steps/ticket_steps.py +++ b/features/steps/ticket_steps.py @@ -536,6 +536,22 @@ def step_ticket_has_timestamp_in_notes(context, ticket_id): f"No timestamp found in notes\nContent: {content}" +@then(r'ticket "(?P[^"]+)" should be archived') +def step_ticket_is_archived(context, ticket_id): + """Assert ticket moved from the active directory into the archive.""" + tickets_dir = Path(context.test_dir) / '.tickets' + assert not (tickets_dir / f'{ticket_id}.md').exists() + assert (tickets_dir / 'archive' / f'{ticket_id}.md').exists() + + +@then(r'ticket "(?P[^"]+)" should remain active') +def step_ticket_remains_active(context, ticket_id): + """Assert ticket is present in the active ticket directory.""" + tickets_dir = Path(context.test_dir) / '.tickets' + assert (tickets_dir / f'{ticket_id}.md').exists() + assert not (tickets_dir / 'archive' / f'{ticket_id}.md').exists() + + @then(r'the output line (?P\d+) should contain "(?P[^"]+)"') def step_output_line_contains(context, line_num, text): """Assert specific line of output contains text.""" diff --git a/features/ticket_archive.feature b/features/ticket_archive.feature new file mode 100644 index 00000000..c0705e83 --- /dev/null +++ b/features/ticket_archive.feature @@ -0,0 +1,69 @@ +Feature: Ticket Archive + As a user with a large ticket store + I want to archive closed tickets + So that routine commands stay fast without losing history + + Background: + Given a clean tickets directory + + Scenario: Archive all closed tickets + Given a ticket exists with ID "done-0001" and title "Done ticket" + And ticket "done-0001" has status "closed" + And a ticket exists with ID "open-0001" and title "Open ticket" + When I run "ticket archive" + Then the command should succeed + And the output should contain "Archived 1 ticket(s)" + And ticket "done-0001" should be archived + And ticket "open-0001" should remain active + + Scenario: Archived dependency remains resolved + Given a ticket exists with ID "ready-0001" and title "Ready ticket" + And a ticket exists with ID "done-0001" and title "Done dependency" + And ticket "ready-0001" depends on "done-0001" + And ticket "done-0001" has status "closed" + When I run "ticket archive done-0001" + And I run "ticket ready" + Then the command should succeed + And the output should contain "ready-0001" + + Scenario: Show archived ticket with relationships + Given a ticket exists with ID "done-0001" and title "Done ticket" + And a ticket exists with ID "open-0001" and title "Open child" with parent "done-0001" + And ticket "done-0001" has status "closed" + When I run "ticket archive done-0001" + And I run "ticket show done-0001" + Then the command should succeed + And the output should contain "# Done ticket" + And the output should contain "open-0001 [open] Open child" + + Scenario: Reopen archived ticket restores it to active tickets + Given a ticket exists with ID "done-0001" and title "Done ticket" + And ticket "done-0001" has status "closed" + When I run "ticket archive done-0001" + And I run "ticket reopen done-0001" + Then the command should succeed + And ticket "done-0001" should remain active + And ticket "done-0001" should have field "status" with value "open" + + Scenario: List includes archived closed tickets + Given a ticket exists with ID "done-0001" and title "Done ticket" + And ticket "done-0001" has status "closed" + When I run "ticket archive done-0001" + And I run "ticket ls --status=closed" + Then the command should succeed + And the output should contain "done-0001" + + Scenario: Archived relationships require reopening + Given a ticket exists with ID "done-0001" and title "Done ticket" + And a ticket exists with ID "open-0001" and title "Open ticket" + And ticket "done-0001" has status "closed" + When I run "ticket archive done-0001" + And I run "ticket link done-0001 open-0001" + Then the command should fail + And the output should contain "must be reopened before editing relationships" + + Scenario: Open ticket cannot be archived + Given a ticket exists with ID "open-0001" and title "Open ticket" + When I run "ticket archive open-0001" + Then the command should fail + And the output should contain "only closed tickets can be archived" diff --git a/plugins/ticket-edit b/plugins/ticket-edit index c4f32d42..22ca18d4 100755 --- a/plugins/ticket-edit +++ b/plugins/ticket-edit @@ -1,6 +1,6 @@ #!/usr/bin/env bash # tk-plugin: Open ticket in $EDITOR -# tk-plugin-version: 1.0.0 +# tk-plugin-version: 1.0.1 set -euo pipefail if [[ $# -lt 1 ]]; then @@ -14,7 +14,7 @@ read -r id <<< "$id" # Resolve ticket file (exact or partial match) file="$TICKETS_DIR/${id}.md" if [[ ! -f "$file" ]]; then - matches=$(find "$TICKETS_DIR" -maxdepth 1 -name "*${id}*.md" 2>/dev/null) + matches=$(find "$TICKETS_DIR" -maxdepth 2 -type f -name "*${id}*.md" 2>/dev/null) count=$(printf '%s\n' "$matches" | grep -c . 2>/dev/null || echo 0) if [[ "$count" -eq 1 ]]; then file="$matches" @@ -27,6 +27,11 @@ if [[ ! -f "$file" ]]; then fi fi +if [[ "$file" == "$TICKETS_DIR/archive/"*.md ]]; then + echo "Error: archived ticket '$(basename "$file" .md)' must be reopened before editing" >&2 + exit 1 +fi + if [ -t 0 ] && [ -t 1 ]; then "${EDITOR:-vi}" "$file" else diff --git a/plugins/ticket-ls b/plugins/ticket-ls index 5bb44b1c..b313a9d9 100755 --- a/plugins/ticket-ls +++ b/plugins/ticket-ls @@ -1,6 +1,6 @@ #!/usr/bin/env bash # tk-plugin: List tickets with optional filters -# tk-plugin-version: 1.0.0 +# tk-plugin-version: 1.1.0 set -euo pipefail status_filter="" assignee_filter="" tag_filter="" @@ -15,8 +15,21 @@ while [[ $# -gt 0 ]]; do esac done +metadata_files=() +for file in "$TICKETS_DIR"/*.md; do + [[ -f "$file" ]] && metadata_files+=("$file") +done +[[ -f "$TICKETS_DIR/archive/index" ]] && metadata_files+=("$TICKETS_DIR/archive/index") +[[ ${#metadata_files[@]} -eq 0 ]] && metadata_files+=("/dev/null") + awk -v status_filter="$status_filter" -v assignee_filter="$assignee_filter" -v tag_filter="$tag_filter" ' BEGIN { FS=": "; in_front=0 } +/^@@tk-file / { + if (prev_file) emit() + id=""; status=""; title=""; deps=""; assignee=""; tags=""; in_front=0 + prev_file=substr($0, 11) + next +} FNR==1 { if (prev_file) emit() id=""; status=""; title=""; deps=""; assignee=""; tags=""; in_front=0 @@ -46,4 +59,4 @@ function emit() { printf "%-8s [%s] - %s%s\n", id, status, title, dep_str } } -' "$TICKETS_DIR"/*.md 2>/dev/null +' "${metadata_files[@]}" 2>/dev/null diff --git a/plugins/ticket-query b/plugins/ticket-query index 342c1b4f..aa2650b6 100755 --- a/plugins/ticket-query +++ b/plugins/ticket-query @@ -1,11 +1,17 @@ #!/usr/bin/env bash # tk-plugin: Output tickets as JSON, optionally filtered with jq -# tk-plugin-version: 1.0.0 +# tk-plugin-version: 1.0.1 set -euo pipefail filter="${1:-}" # Generate all JSON in one awk pass +ticket_files=() +for file in "$TICKETS_DIR"/*.md "$TICKETS_DIR"/archive/*.md; do + [[ -f "$file" ]] && ticket_files+=("$file") +done +[[ ${#ticket_files[@]} -eq 0 ]] && ticket_files+=("/dev/null") + json_output=$(awk ' BEGIN { FS=": "; in_front=0 } FNR==1 { @@ -48,7 +54,7 @@ function emit() { } } END { if (prev_file) emit() } -' "$TICKETS_DIR"/*.md 2>/dev/null) +' "${ticket_files[@]}" 2>/dev/null) if [[ -n "$filter" ]]; then echo "$json_output" | jq -c "select($filter)" diff --git a/ticket b/ticket index 0aea72c1..343f7c21 100755 --- a/ticket +++ b/ticket @@ -64,6 +64,74 @@ else _grep() { grep "$@"; } fi +ARCHIVE_SUBDIR="archive" +ARCHIVE_INDEX="index" + +archive_dir() { + echo "$TICKETS_DIR/$ARCHIVE_SUBDIR" +} + +archive_index() { + echo "$(archive_dir)/$ARCHIVE_INDEX" +} + +is_archived_ticket() { + [[ "$1" == "$(archive_dir)/"*.md ]] +} + +# Populate the argv list used by metadata-heavy commands. Active tickets remain +# individual files; archived tickets are represented by one compact index. +set_ticket_metadata_files() { + TICKET_METADATA_FILES=() + local file index + for file in "$TICKETS_DIR"/*.md; do + [[ -f "$file" ]] && TICKET_METADATA_FILES+=("$file") + done + index=$(archive_index) + [[ -f "$index" ]] && TICKET_METADATA_FILES+=("$index") + [[ ${#TICKET_METADATA_FILES[@]} -eq 0 ]] && TICKET_METADATA_FILES+=("/dev/null") + return 0 +} + +rebuild_archive_index() { + local dir index tmp file + local -a files=() + dir=$(archive_dir) + index=$(archive_index) + mkdir -p "$dir" + + for file in "$dir"/*.md; do + [[ -f "$file" ]] && files+=("$file") + done + + tmp="${index}.tmp.$$" + if [[ ${#files[@]} -eq 0 ]]; then + : > "$tmp" + else + awk ' + FNR == 1 { + name = FILENAME + sub(/^.*\//, "", name) + print "@@tk-file " name + in_front = 0 + have_title = 0 + } + /^---$/ { in_front = !in_front; print; next } + in_front && /^(id|status|deps|links|parent|priority|assignee|tags):/ { print; next } + !in_front && /^# / && !have_title { print; have_title = 1; nextfile } + ' "${files[@]}" > "$tmp" + fi + mv "$tmp" "$index" +} + +require_active_ticket() { + if is_archived_ticket "$1"; then + echo "Error: archived ticket '$(basename "$1" .md)' must be reopened before editing relationships" >&2 + return 1 + fi + return 0 +} + # Portable ISO date (GNU date supports -Iseconds, BSD date does not) _iso_date() { date -u +%Y-%m-%dT%H:%M:%SZ @@ -107,15 +175,21 @@ ticket_path() { # Trim leading/trailing whitespace (handles Claude/agent quirks) read -r id <<< "$id" local exact="$TICKETS_DIR/${id}.md" + local archived_exact + archived_exact="$(archive_dir)/${id}.md" if [[ -f "$exact" ]]; then echo "$exact" return 0 fi + if [[ -f "$archived_exact" ]]; then + echo "$archived_exact" + return 0 + fi # Try partial match (anywhere in filename) local matches - matches=$(find "$TICKETS_DIR" -maxdepth 1 -name "*${id}*.md" 2>/dev/null | head -2) + matches=$(find "$TICKETS_DIR" -maxdepth 2 -type f -name "*${id}*.md" 2>/dev/null | head -2) local count count=$(echo "$matches" | _grep -c . || true) @@ -261,6 +335,12 @@ cmd_status() { file=$(ticket_path "$id") || return 1 update_yaml_field "$file" "status" "$status" + if is_archived_ticket "$file" && [[ "$status" != "closed" ]]; then + local restored="$TICKETS_DIR/$(basename "$file")" + mv "$file" "$restored" + rebuild_archive_index + file="$restored" + fi echo "Updated $(basename "$file" .md) -> $status" } @@ -304,8 +384,15 @@ cmd_dep_tree() { return 1 fi + set_ticket_metadata_files awk -v root_pattern="$root_id" -v full_mode="$full_mode" ' BEGIN { FS=": "; in_front=0 } + /^@@tk-file / { + if (prev_file) store() + id=""; status=""; title=""; deps=""; in_front=0 + prev_file=substr($0, 11) + next + } FNR==1 { if (prev_file) store() id=""; status=""; title=""; deps=""; in_front=0 @@ -484,12 +571,19 @@ cmd_dep_tree() { print_stack_path[print_sp] = path } } - ' "$TICKETS_DIR"/*.md 2>/dev/null + ' "${TICKET_METADATA_FILES[@]}" 2>/dev/null } cmd_dep_cycle() { + set_ticket_metadata_files awk ' BEGIN { FS=": "; in_front=0 } + /^@@tk-file / { + if (prev_file) store() + id=""; status=""; title=""; deps=""; in_front=0 + prev_file=substr($0, 11) + next + } FNR==1 { if (prev_file) store() id=""; status=""; title=""; deps=""; in_front=0 @@ -595,7 +689,7 @@ cmd_dep_cycle() { } } } - ' "$TICKETS_DIR"/*.md 2>/dev/null + ' "${TICKET_METADATA_FILES[@]}" 2>/dev/null } cmd_dep() { @@ -622,6 +716,7 @@ cmd_dep() { local dep_id="$2" local file file=$(ticket_path "$id") || return 1 + require_active_ticket "$file" || return 1 # Verify dependency exists and resolve to full ID local dep_file @@ -662,8 +757,15 @@ cmd_ready() { esac done + set_ticket_metadata_files awk -v assignee_filter="$assignee_filter" -v tag_filter="$tag_filter" ' BEGIN { FS=": "; in_front=0 } + /^@@tk-file / { + if (prev_file) store() + id=""; status=""; title=""; deps=""; priority=""; assignee=""; tags=""; in_front=0 + prev_file=substr($0, 11) + next + } FNR==1 { if (prev_file) store() id=""; status=""; title=""; deps=""; priority=""; assignee=""; tags=""; in_front=0 @@ -737,7 +839,7 @@ cmd_ready() { printf "%-8s [P%s][%s] - %s\n", f[2], f[1], f[3], f[4] } } - ' "$TICKETS_DIR"/*.md 2>/dev/null + ' "${TICKET_METADATA_FILES[@]}" 2>/dev/null } cmd_closed() { @@ -754,10 +856,18 @@ cmd_closed() { done # List files by mtime (most recent first), filter closed, limit output - local files - files=$(ls -t "$TICKETS_DIR"/*.md 2>/dev/null | head -n 100) - [[ -z "$files" ]] && return 0 - echo "$files" | xargs awk -v assignee_filter="$assignee_filter" -v tag_filter="$tag_filter" ' + local file + local -a candidates=() recent=() + for file in "$TICKETS_DIR"/*.md "$(archive_dir)"/*.md; do + [[ -f "$file" ]] && candidates+=("$file") + done + [[ ${#candidates[@]} -eq 0 ]] && return 0 + while IFS= read -r file && [[ ${#recent[@]} -lt 100 ]]; do + [[ -n "$file" ]] && recent+=("$file") + done < <(ls -t "${candidates[@]}" 2>/dev/null) + [[ ${#recent[@]} -eq 0 ]] && return 0 + + awk -v limit="$limit" -v assignee_filter="$assignee_filter" -v tag_filter="$tag_filter" ' BEGIN { FS=": "; in_front=0 } FNR==1 { if (prev_file) emit() @@ -776,15 +886,16 @@ cmd_closed() { return 0 } function emit() { - if (id != "" && (status == "closed" || status == "done") && (assignee_filter == "" || assignee == assignee_filter) && (tag_filter == "" || has_tag(tags, tag_filter))) { + if (emitted < limit && id != "" && (status == "closed" || status == "done") && (assignee_filter == "" || assignee == assignee_filter) && (tag_filter == "" || has_tag(tags, tag_filter))) { output[++count] = sprintf("%-8s [%s] - %s", id, status, title) + emitted++ } } END { if (prev_file) emit() for (i = 1; i <= count; i++) print output[i] } - ' | head -n "$limit" + ' "${recent[@]}" } cmd_blocked() { @@ -799,8 +910,15 @@ cmd_blocked() { esac done + set_ticket_metadata_files awk -v assignee_filter="$assignee_filter" -v tag_filter="$tag_filter" ' BEGIN { FS=": "; in_front=0 } + /^@@tk-file / { + if (prev_file) store() + id=""; status=""; title=""; deps=""; priority=""; assignee=""; tags=""; in_front=0 + prev_file=substr($0, 11) + next + } FNR==1 { if (prev_file) store() id=""; status=""; title=""; deps=""; priority=""; assignee=""; tags=""; in_front=0 @@ -883,7 +1001,7 @@ cmd_blocked() { printf "%-8s [P%s][%s] - %s <- %s\n", f[2], f[1], f[3], f[4], f[5] } } - ' "$TICKETS_DIR"/*.md 2>/dev/null + ' "${TICKET_METADATA_FILES[@]}" 2>/dev/null } cmd_undep() { @@ -896,6 +1014,7 @@ cmd_undep() { local dep_id="$2" local file file=$(ticket_path "$id") || return 1 + require_active_ticket "$file" || return 1 # Resolve dep_id to full ID local dep_file @@ -931,6 +1050,7 @@ cmd_link() { for arg in "$@"; do local file file=$(ticket_path "$arg") || return 1 + require_active_ticket "$file" || return 1 ids+=("$(basename "$file" .md)") files+=("$file") done @@ -1009,6 +1129,8 @@ cmd_unlink() { local file target_file file=$(ticket_path "$1") || return 1 target_file=$(ticket_path "$2") || return 1 + require_active_ticket "$file" || return 1 + require_active_ticket "$target_file" || return 1 local id target_id id=$(basename "$file" .md) target_id=$(basename "$target_file" .md) @@ -1036,6 +1158,61 @@ cmd_unlink() { echo "Removed link: $id <-> $target_id" } +cmd_archive() { + local reindex_only=0 had_ids=0 arg file status dir existing + local -a files=() + + while [[ $# -gt 0 ]]; do + case "$1" in + --reindex) reindex_only=1; shift ;; + -*) echo "Unknown option: $1" >&2; return 1 ;; + *) + had_ids=1 + file=$(ticket_path "$1") || return 1 + if is_archived_ticket "$file"; then + shift + continue + fi + status=$(yaml_field "$file" "status") + if [[ "$status" != "closed" ]]; then + echo "Error: ticket '$(basename "$file" .md)' is $status; only closed tickets can be archived" >&2 + return 1 + fi + existing=0 + for arg in "${files[@]}"; do + [[ "$arg" == "$file" ]] && existing=1 + done + [[ $existing -eq 0 ]] && files+=("$file") + shift + ;; + esac + done + + if [[ $reindex_only -eq 1 && $had_ids -eq 1 ]]; then + echo "Error: --reindex cannot be combined with ticket IDs" >&2 + return 1 + fi + + if [[ $reindex_only -eq 0 && $had_ids -eq 0 ]]; then + while IFS= read -r file; do + [[ -n "$file" ]] && files+=("$file") + done < <(awk '/^status: closed$/ { print FILENAME; nextfile }' "$TICKETS_DIR"/*.md 2>/dev/null) + fi + + dir=$(archive_dir) + mkdir -p "$dir" + if [[ ${#files[@]} -gt 0 ]]; then + mv "${files[@]}" "$dir/" + fi + rebuild_archive_index + + if [[ $reindex_only -eq 1 ]]; then + echo "Rebuilt archive index" + else + echo "Archived ${#files[@]} ticket(s)" + fi +} + cmd_show() { if [[ $# -lt 1 ]]; then echo "Usage: ticket show " >&2 @@ -1048,10 +1225,17 @@ cmd_show() { target_id=$(basename "$file" .md) _show_output() { + set_ticket_metadata_files awk -v target="$target_id" -v target_file="$file" ' BEGIN { FS=": "; in_front=0 } # First pass: collect all ticket metadata + /^@@tk-file / { + if (prev_file) store() + id=""; status=""; title=""; deps=""; links=""; parent=""; in_front=0 + prev_file=substr($0, 11) + next + } FNR==1 { if (prev_file) store() id=""; status=""; title=""; deps=""; links=""; parent=""; in_front=0 @@ -1179,7 +1363,7 @@ cmd_show() { } } } - ' "$TICKETS_DIR"/*.md 2>/dev/null + ' "${TICKET_METADATA_FILES[@]}" 2>/dev/null } if [[ -t 1 && -n "$TICKET_PAGER" ]]; then @@ -1286,6 +1470,8 @@ Commands: undep Remove dependency link [id...] Link tickets together (symmetric) unlink Remove link between tickets + archive [id...] Move closed tickets into the indexed archive + --reindex Rebuild archive metadata without moving tickets ready [-a X] [-T X] List open/in-progress tickets with deps resolved blocked [-a X] [-T X] List open/in-progress tickets with unresolved deps closed [--limit=N] [-a X] [-T X] List recently closed tickets (default 20, by mtime) @@ -1369,6 +1555,7 @@ case "${1:-help}" in undep) shift; cmd_undep "$@" ;; link) shift; cmd_link "$@" ;; unlink) shift; cmd_unlink "$@" ;; + archive) shift; cmd_archive "$@" ;; ready) shift; cmd_ready "$@" ;; blocked) shift; cmd_blocked "$@" ;; closed) shift; cmd_closed "$@" ;;