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
17 changes: 14 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ Commands:
undep <id> <dep-id> Remove dependency
link <id> <id> [id...] Link tickets together (symmetric)
unlink <id> <target-id> 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
Expand All @@ -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-<cmd>` or `ticket-<cmd>` in your PATH are invoked automatically. This allows you to add custom commands or override built-in ones.
Expand Down
16 changes: 16 additions & 0 deletions features/steps/ticket_steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<ticket_id>[^"]+)" 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<ticket_id>[^"]+)" 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<line_num>\d+) should contain "(?P<text>[^"]+)"')
def step_output_line_contains(context, line_num, text):
"""Assert specific line of output contains text."""
Expand Down
69 changes: 69 additions & 0 deletions features/ticket_archive.feature
Original file line number Diff line number Diff line change
@@ -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"
9 changes: 7 additions & 2 deletions plugins/ticket-edit
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"
Expand All @@ -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
Expand Down
17 changes: 15 additions & 2 deletions plugins/ticket-ls
Original file line number Diff line number Diff line change
@@ -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=""
Expand All @@ -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
Expand Down Expand Up @@ -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
10 changes: 8 additions & 2 deletions plugins/ticket-query
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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)"
Expand Down
Loading