From 1e07e5e5cebe38266985cae89c1a4c319152de5d Mon Sep 17 00:00:00 2001 From: Ray Myers Date: Fri, 16 Jan 2026 18:56:15 -0600 Subject: [PATCH 1/2] Add Behave tests --- Makefile | 4 + README.md | 10 + features/environment.py | 41 ++ features/id_resolution.feature | 71 ++++ features/requirements.txt | 1 + features/steps/ticket_steps.py | 539 +++++++++++++++++++++++++++ features/ticket_creation.feature | 98 +++++ features/ticket_dependencies.feature | 145 +++++++ features/ticket_edit.feature | 24 ++ features/ticket_links.feature | 58 +++ features/ticket_listing.feature | 157 ++++++++ features/ticket_notes.feature | 48 +++ features/ticket_query.feature | 48 +++ features/ticket_show.feature | 87 +++++ features/ticket_status.feature | 62 +++ 15 files changed, 1393 insertions(+) create mode 100644 Makefile create mode 100644 features/environment.py create mode 100644 features/id_resolution.feature create mode 100644 features/requirements.txt create mode 100644 features/steps/ticket_steps.py create mode 100644 features/ticket_creation.feature create mode 100644 features/ticket_dependencies.feature create mode 100644 features/ticket_edit.feature create mode 100644 features/ticket_links.feature create mode 100644 features/ticket_listing.feature create mode 100644 features/ticket_notes.feature create mode 100644 features/ticket_query.feature create mode 100644 features/ticket_show.feature create mode 100644 features/ticket_status.feature diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..55f82a7e --- /dev/null +++ b/Makefile @@ -0,0 +1,4 @@ +.PHONY: test + +test: + uv run --with behave behave diff --git a/README.md b/README.md index 8b3d0345..72ff099b 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,16 @@ Tickets stored as markdown files in .tickets/ Supports partial ID matching (e.g., 'tk show 5c4' matches 'nw-5c46') ``` +## Testing + +The tests are written in the Behavior-Driven Development library [behave](https://behave.readthedocs.io/en/latest/) and require Python. + +If you have `uv` [installed](https://docs.astral.sh/uv/getting-started/installation/) simply: + +```sh +make test +``` + ## Migrating from Beads ```bash diff --git a/features/environment.py b/features/environment.py new file mode 100644 index 00000000..3918859e --- /dev/null +++ b/features/environment.py @@ -0,0 +1,41 @@ +"""Behave environment setup for ticket CLI tests.""" + +import os +import shutil +import tempfile +from pathlib import Path + + +def before_all(context): + """Set up test environment before all tests.""" + # Store the project directory (where the ticket script lives) + context.project_dir = Path(__file__).parent.parent.resolve() + + +def before_scenario(context, scenario): + """Create a fresh temporary directory for each scenario.""" + # Create a temporary directory for this scenario + context.test_dir = tempfile.mkdtemp(prefix='ticket_test_') + + # Initialize tracking + context.tickets = {} + context.last_created_id = None + context.stdout = '' + context.stderr = '' + context.returncode = None + + +def after_scenario(context, scenario): + """Clean up temporary directory after each scenario.""" + if hasattr(context, 'test_dir') and os.path.exists(context.test_dir): + shutil.rmtree(context.test_dir) + + +def before_feature(context, feature): + """Called before each feature file is processed.""" + pass + + +def after_feature(context, feature): + """Called after each feature file is processed.""" + pass diff --git a/features/id_resolution.feature b/features/id_resolution.feature new file mode 100644 index 00000000..b0564631 --- /dev/null +++ b/features/id_resolution.feature @@ -0,0 +1,71 @@ +Feature: Ticket ID Resolution + As a user + I want to use partial ticket IDs + So that I can work faster without typing full IDs + + Background: + Given a clean tickets directory + + Scenario: Exact ID match + Given a ticket exists with ID "abc-1234" and title "Test ticket" + When I run "ticket show abc-1234" + Then the command should succeed + And the output should contain "id: abc-1234" + + Scenario: Partial ID match by suffix + Given a ticket exists with ID "abc-1234" and title "Test ticket" + When I run "ticket show 1234" + Then the command should succeed + And the output should contain "id: abc-1234" + + Scenario: Partial ID match by prefix + Given a ticket exists with ID "abc-1234" and title "Test ticket" + When I run "ticket show abc" + Then the command should succeed + And the output should contain "id: abc-1234" + + Scenario: Partial ID match by substring + Given a ticket exists with ID "abc-1234" and title "Test ticket" + When I run "ticket show c-12" + Then the command should succeed + And the output should contain "id: abc-1234" + + Scenario: Ambiguous ID error + Given a ticket exists with ID "abc-1234" and title "First ticket" + And a ticket exists with ID "abc-5678" and title "Second ticket" + When I run "ticket show abc" + Then the command should fail + And the output should contain "Error: ambiguous ID 'abc' matches multiple tickets" + + Scenario: Non-existent ID error + When I run "ticket show nonexistent" + Then the command should fail + And the output should contain "Error: ticket 'nonexistent' not found" + + Scenario: Exact match takes precedence + Given a ticket exists with ID "abc" and title "Short ID ticket" + And a ticket exists with ID "abc-1234" and title "Long ID ticket" + When I run "ticket show abc" + Then the command should succeed + And the output should contain "id: abc" + And the output should contain "Short ID ticket" + + Scenario: ID resolution works with status command + Given a ticket exists with ID "test-9999" and title "Test ticket" + When I run "ticket status 9999 in_progress" + Then the command should succeed + And ticket "test-9999" should have field "status" with value "in_progress" + + Scenario: ID resolution works with dep command + Given a ticket exists with ID "dep-aaaa" and title "Main" + And a ticket exists with ID "dep-bbbb" and title "Dependency" + When I run "ticket dep aaaa bbbb" + Then the command should succeed + And ticket "dep-aaaa" should have "bbbb" in deps + + Scenario: ID resolution works with link command + Given a ticket exists with ID "link-cccc" and title "First" + And a ticket exists with ID "link-dddd" and title "Second" + When I run "ticket link cccc dddd" + Then the command should succeed + And ticket "link-cccc" should have "link-dddd" in links diff --git a/features/requirements.txt b/features/requirements.txt new file mode 100644 index 00000000..97d1c49c --- /dev/null +++ b/features/requirements.txt @@ -0,0 +1 @@ +behave>=1.2.6 diff --git a/features/steps/ticket_steps.py b/features/steps/ticket_steps.py new file mode 100644 index 00000000..088df59d --- /dev/null +++ b/features/steps/ticket_steps.py @@ -0,0 +1,539 @@ +"""Step definitions for ticket CLI BDD tests.""" + +import json +import os +import re +import subprocess +import tempfile +from pathlib import Path + +from behave import given, when, then, register_type, use_step_matcher +import parse + + +# Use regex matcher for more flexible step definitions +use_step_matcher("re") + + +# ============================================================================ +# Helper Functions +# ============================================================================ + +def get_ticket_script(context): + """Get the ticket script path, defaulting to ./ticket or using TICKET_SCRIPT env var.""" + ticket_script = os.environ.get('TICKET_SCRIPT') + if ticket_script: + return ticket_script + return str(Path(context.project_dir) / 'ticket') + + +def create_ticket(context, ticket_id, title, priority=2, parent=None): + """Helper to create a ticket file.""" + tickets_dir = Path(context.test_dir) / '.tickets' + tickets_dir.mkdir(parents=True, exist_ok=True) + + ticket_path = tickets_dir / f'{ticket_id}.md' + content = f'''--- +id: {ticket_id} +status: open +deps: [] +links: [] +created: 2024-01-01T00:00:00Z +type: task +priority: {priority} +''' + if parent: + content += f'parent: {parent}\n' + content += f'''--- +# {title} + +Description +''' + ticket_path.write_text(content) + + if not hasattr(context, 'tickets'): + context.tickets = {} + context.tickets[ticket_id] = ticket_path + return ticket_path + + +# ============================================================================ +# Given Steps +# ============================================================================ + +@given(r'a clean tickets directory') +def step_clean_tickets_directory(context): + """Ensure we start with a clean .tickets directory.""" + tickets_dir = Path(context.test_dir) / '.tickets' + if tickets_dir.exists(): + import shutil + shutil.rmtree(tickets_dir) + tickets_dir.mkdir(parents=True, exist_ok=True) + + +@given(r'the tickets directory does not exist') +def step_tickets_dir_not_exist(context): + """Ensure .tickets directory does not exist.""" + tickets_dir = Path(context.test_dir) / '.tickets' + if tickets_dir.exists(): + import shutil + shutil.rmtree(tickets_dir) + + +@given(r'a ticket exists with ID "(?P[^"]+)" and title "(?P[^"]+)" with priority (?P<priority>\d+)') +def step_ticket_exists_with_priority(context, ticket_id, title, priority): + """Create a ticket with given ID, title, and 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 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.""" + create_ticket(context, ticket_id, title, parent=parent_id) + + +@given(r'a ticket exists with ID "(?P<ticket_id>[^"]+)" and title "(?P<title>[^"]+)"') +def step_ticket_exists(context, ticket_id, title): + """Create a ticket with given ID and title (basic, no extra params).""" + # This is the most generic form - the more specific ones should be defined first + create_ticket(context, ticket_id, title) + + +@given(r'ticket "(?P<ticket_id>[^"]+)" has status "(?P<status>[^"]+)"') +def step_ticket_has_status(context, ticket_id, status): + """Set ticket status.""" + ticket_path = Path(context.test_dir) / '.tickets' / f'{ticket_id}.md' + content = ticket_path.read_text() + content = re.sub(r'^status: \w+', f'status: {status}', 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.""" + ticket_path = Path(context.test_dir) / '.tickets' / f'{ticket_id}.md' + content = ticket_path.read_text() + + # Parse current deps + deps_match = re.search(r'^deps: \[(.*?)\]', content, re.MULTILINE) + if deps_match: + current_deps = deps_match.group(1) + if current_deps: + deps_list = [d.strip() for d in current_deps.split(',')] + if dep_id not in deps_list: + deps_list.append(dep_id) + else: + deps_list = [dep_id] + new_deps = ', '.join(deps_list) + content = re.sub(r'^deps: \[.*?\]', f'deps: [{new_deps}]', content, flags=re.MULTILINE) + + ticket_path.write_text(content) + + +@given(r'ticket "(?P<ticket_id>[^"]+)" is linked to "(?P<link_id>[^"]+)"') +def step_ticket_linked_to(context, ticket_id, link_id): + """Create bidirectional link between tickets.""" + # Update first ticket + ticket_path = Path(context.test_dir) / '.tickets' / f'{ticket_id}.md' + content = ticket_path.read_text() + links_match = re.search(r'^links: \[(.*?)\]', content, re.MULTILINE) + if links_match: + current_links = links_match.group(1) + if current_links: + links_list = [l.strip() for l in current_links.split(',')] + if link_id not in links_list: + links_list.append(link_id) + else: + links_list = [link_id] + new_links = ', '.join(links_list) + content = re.sub(r'^links: \[.*?\]', f'links: [{new_links}]', content, flags=re.MULTILINE) + ticket_path.write_text(content) + + # Update second ticket + link_path = Path(context.test_dir) / '.tickets' / f'{link_id}.md' + content = link_path.read_text() + links_match = re.search(r'^links: \[(.*?)\]', content, re.MULTILINE) + if links_match: + current_links = links_match.group(1) + if current_links: + links_list = [l.strip() for l in current_links.split(',')] + if ticket_id not in links_list: + links_list.append(ticket_id) + else: + links_list = [ticket_id] + new_links = ', '.join(links_list) + content = re.sub(r'^links: \[.*?\]', f'links: [{new_links}]', content, flags=re.MULTILINE) + link_path.write_text(content) + + +@given(r'ticket "(?P<ticket_id>[^"]+)" has a notes section') +def step_ticket_has_notes(context, ticket_id): + """Ensure ticket has a notes section.""" + ticket_path = Path(context.test_dir) / '.tickets' / f'{ticket_id}.md' + content = ticket_path.read_text() + if '## Notes' not in content: + content += '\n## Notes\n' + ticket_path.write_text(content) + + +# ============================================================================ +# When Steps +# ============================================================================ + +@when(r'I run "(?P<command>(?:[^"\\]|\\.)+)" in non-TTY mode') +def step_run_command_non_tty(context, command): + """Run a command simulating non-TTY mode.""" + # Unescape \" to " in the command string + command = command.replace('\\"', '"') + + ticket_script = get_ticket_script(context) + cmd = command.replace('ticket ', f'{ticket_script} ', 1) + + result = subprocess.run( + cmd, + shell=True, + cwd=context.test_dir, + capture_output=True, + text=True, + stdin=subprocess.DEVNULL # Simulate non-TTY + ) + + context.result = result + context.stdout = result.stdout.strip() + context.stderr = result.stderr.strip() + context.returncode = result.returncode + + +@when(r'I run "(?P<command>(?:[^"\\]|\\.)+)" with no stdin') +def step_run_command_no_stdin(context, command): + """Run a command with stdin closed.""" + ticket_script = get_ticket_script(context) + cmd = command.replace('ticket ', f'{ticket_script} ', 1) + + result = subprocess.run( + cmd, + shell=True, + cwd=context.test_dir, + capture_output=True, + text=True, + stdin=subprocess.DEVNULL + ) + + context.result = result + context.stdout = result.stdout.strip() + context.stderr = result.stderr.strip() + context.returncode = result.returncode + + +@when(r'I run "(?P<command>(?:[^"\\]|\\.)+)"') +def step_run_command(context, command): + """Run a ticket CLI command.""" + # Unescape \" to " in the command string + command = command.replace('\\"', '"') + + ticket_script = get_ticket_script(context) + cmd = command.replace('ticket ', f'{ticket_script} ', 1) + + result = subprocess.run( + cmd, + shell=True, + cwd=context.test_dir, + capture_output=True, + text=True, + stdin=subprocess.DEVNULL # Non-interactive tests + ) + + context.result = result + context.stdout = result.stdout.strip() + context.stderr = result.stderr.strip() + context.returncode = result.returncode + context.last_command = command + + # If this was a create command, track the created ticket ID + if 'ticket create' in command and result.returncode == 0: + context.last_created_id = result.stdout.strip() + + +# ============================================================================ +# Then Steps +# ============================================================================ + +@then(r'the command should succeed') +def step_command_succeed(context): + """Assert command returned exit code 0.""" + assert context.returncode == 0, \ + f"Command failed with exit code {context.returncode}\nstdout: {context.stdout}\nstderr: {context.stderr}" + + +@then(r'the command should fail') +def step_command_fail(context): + """Assert command returned non-zero exit code.""" + assert context.returncode != 0, \ + f"Command succeeded but was expected to fail\nstdout: {context.stdout}" + + +@then(r'the output should be "(?P<expected>[^"]*)"') +def step_output_equals(context, expected): + """Assert output exactly matches expected string.""" + actual = context.stdout + assert actual == expected, f"Expected '{expected}' but got '{actual}'" + + +@then(r'the output should be empty') +def step_output_empty(context): + """Assert output is empty.""" + assert context.stdout == '', f"Expected empty output but got: {context.stdout}" + + +@then(r'the output should contain "(?P<text>[^"]+)"') +def step_output_contains(context, text): + """Assert output contains text.""" + output = context.stdout + context.stderr + assert text in output, f"Expected output to contain '{text}'\nActual output: {output}" + + +@then(r'the output should not contain "(?P<text>[^"]+)"') +def step_output_not_contains(context, text): + """Assert output does not contain text.""" + output = context.stdout + context.stderr + assert text not in output, f"Expected output to NOT contain '{text}'\nActual output: {output}" + + +@then(r'the output should match a ticket ID pattern') +def step_output_matches_id_pattern(context): + """Assert output matches ticket ID pattern (prefix-hash).""" + # Prefix can be alphanumeric (from directory name), hash is 4 hex chars + pattern = r'^[a-z0-9]+-[a-f0-9]{4}$' + assert re.match(pattern, context.stdout), \ + f"Output '{context.stdout}' does not match ticket ID pattern" + + +@then(r'the output should match pattern "(?P<pattern>[^"]+)"') +def step_output_matches_pattern(context, pattern): + """Assert output matches regex pattern.""" + assert re.search(pattern, context.stdout), \ + f"Output does not match pattern '{pattern}'\nActual output: {context.stdout}" + + +@then(r'the output should match box-drawing tree format') +def step_output_matches_tree_format(context): + """Assert output contains box-drawing characters for tree.""" + output = context.stdout + has_tree_chars = any(c in output for c in ['├', '└', '│', '─']) + assert has_tree_chars, f"Output does not contain box-drawing characters:\n{output}" + + +@then(r'a ticket file should exist with title "(?P<title>[^"]+)"') +def step_ticket_file_exists_with_title(context, title): + """Assert a ticket file exists with given title.""" + tickets_dir = Path(context.test_dir) / '.tickets' + ticket_id = context.last_created_id + ticket_path = tickets_dir / f'{ticket_id}.md' + + assert ticket_path.exists(), f"Ticket file {ticket_path} does not exist" + content = ticket_path.read_text() + assert f'# {title}' in content, f"Ticket does not have title '{title}'\nContent: {content}" + + +@then(r'the tickets directory should exist') +def step_tickets_dir_exists(context): + """Assert .tickets directory exists.""" + tickets_dir = Path(context.test_dir) / '.tickets' + assert tickets_dir.exists(), f".tickets directory does not exist" + + +@then(r'the created ticket should contain "(?P<text>[^"]+)"') +def step_created_ticket_contains(context, text): + """Assert the most recently created ticket contains text.""" + ticket_id = context.last_created_id + ticket_path = Path(context.test_dir) / '.tickets' / f'{ticket_id}.md' + content = ticket_path.read_text() + assert text in content, f"Ticket does not contain '{text}'\nContent: {content}" + + +@then(r'the created ticket should have field "(?P<field>[^"]+)" with value "(?P<value>[^"]+)"') +def step_created_ticket_has_field(context, field, value): + """Assert the most recently created ticket has a field with value.""" + ticket_id = context.last_created_id + ticket_path = Path(context.test_dir) / '.tickets' / f'{ticket_id}.md' + content = ticket_path.read_text() + + pattern = rf'^{re.escape(field)}:\s*(.+)$' + match = re.search(pattern, content, re.MULTILINE) + assert match, f"Field '{field}' not found in ticket\nContent: {content}" + actual = match.group(1).strip() + assert actual == value, f"Field '{field}' has value '{actual}', expected '{value}'" + + +@then(r'the created ticket should have a valid created timestamp') +def step_created_ticket_has_timestamp(context): + """Assert the created ticket has a valid timestamp.""" + ticket_id = context.last_created_id + ticket_path = Path(context.test_dir) / '.tickets' / f'{ticket_id}.md' + content = ticket_path.read_text() + + pattern = r'^created:\s*\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z' + assert re.search(pattern, content, re.MULTILINE), \ + f"No valid created timestamp found\nContent: {content}" + + +@then(r'ticket "(?P<ticket_id>[^"]+)" should have field "(?P<field>[^"]+)" with value "(?P<value>[^"]+)"') +def step_ticket_has_field_value(context, ticket_id, field, value): + """Assert ticket has a field with specific value.""" + ticket_path = Path(context.test_dir) / '.tickets' / f'{ticket_id}.md' + content = ticket_path.read_text() + + pattern = rf'^{re.escape(field)}:\s*(.+)$' + match = re.search(pattern, content, re.MULTILINE) + assert match, f"Field '{field}' not found in ticket\nContent: {content}" + actual = match.group(1).strip() + assert actual == value, f"Field '{field}' has value '{actual}', expected '{value}'" + + +@then(r'ticket "(?P<ticket_id>[^"]+)" should have "(?P<dep_id>[^"]+)" in deps') +def step_ticket_has_dep(context, ticket_id, dep_id): + """Assert ticket has a dependency.""" + ticket_path = Path(context.test_dir) / '.tickets' / f'{ticket_id}.md' + content = ticket_path.read_text() + + deps_match = re.search(r'^deps:\s*\[([^\]]*)\]', content, re.MULTILINE) + assert deps_match, f"deps field not found\nContent: {content}" + deps = deps_match.group(1) + assert dep_id in deps, f"Dependency '{dep_id}' not in deps: [{deps}]" + + +@then(r'ticket "(?P<ticket_id>[^"]+)" should not have "(?P<dep_id>[^"]+)" in deps') +def step_ticket_not_has_dep(context, ticket_id, dep_id): + """Assert ticket does not have a dependency.""" + ticket_path = Path(context.test_dir) / '.tickets' / f'{ticket_id}.md' + content = ticket_path.read_text() + + deps_match = re.search(r'^deps:\s*\[([^\]]*)\]', content, re.MULTILINE) + assert deps_match, f"deps field not found\nContent: {content}" + deps = deps_match.group(1) + assert dep_id not in deps, f"Dependency '{dep_id}' should not be in deps: [{deps}]" + + +@then(r'ticket "(?P<ticket_id>[^"]+)" should have "(?P<link_id>[^"]+)" in links') +def step_ticket_has_link(context, ticket_id, link_id): + """Assert ticket has a link.""" + ticket_path = Path(context.test_dir) / '.tickets' / f'{ticket_id}.md' + content = ticket_path.read_text() + + links_match = re.search(r'^links:\s*\[([^\]]*)\]', content, re.MULTILINE) + assert links_match, f"links field not found\nContent: {content}" + links = links_match.group(1) + assert link_id in links, f"Link '{link_id}' not in links: [{links}]" + + +@then(r'ticket "(?P<ticket_id>[^"]+)" should not have "(?P<link_id>[^"]+)" in links') +def step_ticket_not_has_link(context, ticket_id, link_id): + """Assert ticket does not have a link.""" + ticket_path = Path(context.test_dir) / '.tickets' / f'{ticket_id}.md' + content = ticket_path.read_text() + + links_match = re.search(r'^links:\s*\[([^\]]*)\]', content, re.MULTILINE) + assert links_match, f"links field not found\nContent: {content}" + links = links_match.group(1) + assert link_id not in links, f"Link '{link_id}' should not be in links: [{links}]" + + +@then(r'ticket "(?P<ticket_id>[^"]+)" should contain "(?P<text>[^"]+)"') +def step_ticket_contains(context, ticket_id, text): + """Assert ticket file contains text.""" + ticket_path = Path(context.test_dir) / '.tickets' / f'{ticket_id}.md' + content = ticket_path.read_text() + assert text in content, f"Ticket does not contain '{text}'\nContent: {content}" + + +@then(r'ticket "(?P<ticket_id>[^"]+)" should contain a timestamp in notes') +def step_ticket_has_timestamp_in_notes(context, ticket_id): + """Assert ticket has a timestamp in notes section.""" + ticket_path = Path(context.test_dir) / '.tickets' / f'{ticket_id}.md' + content = ticket_path.read_text() + + pattern = r'\*\*\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\*\*' + assert re.search(pattern, content), \ + f"No timestamp found in notes\nContent: {content}" + + +@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.""" + line_num = int(line_num) + lines = context.stdout.split('\n') + assert len(lines) >= line_num, \ + f"Output has only {len(lines)} lines, expected at least {line_num}" + line = lines[line_num - 1] + assert text in line, f"Line {line_num} does not contain '{text}'\nLine: {line}" + + +@then(r'the output line count should be (?P<count>\d+)') +def step_output_line_count(context, count): + """Assert output has specific number of lines.""" + count = int(count) + lines = [l for l in context.stdout.split('\n') if l.strip()] + assert len(lines) == count, \ + f"Expected {count} lines but got {len(lines)}\nOutput: {context.stdout}" + + +@then(r'the output should be valid JSONL') +def step_output_valid_jsonl(context): + """Assert output is valid JSON Lines format.""" + lines = context.stdout.strip().split('\n') + for line in lines: + if line.strip(): + try: + json.loads(line) + except json.JSONDecodeError as e: + raise AssertionError(f"Invalid JSONL line: {line}\nError: {e}") + + +@then(r'the JSONL output should have field "(?P<field>[^"]+)"') +def step_jsonl_has_field(context, field): + """Assert JSONL output has a specific field.""" + lines = context.stdout.strip().split('\n') + assert lines, "No JSONL output" + + for line in lines: + if line.strip(): + data = json.loads(line) + assert field in data, f"Field '{field}' not found in JSONL\nData: {data}" + break + + +@then(r'the JSONL deps field should be a JSON array') +def step_jsonl_deps_is_array(context): + """Assert deps field in JSONL is an array.""" + lines = context.stdout.strip().split('\n') + assert lines, "No JSONL output" + + for line in lines: + if line.strip(): + data = json.loads(line) + if 'deps' in data: + assert isinstance(data['deps'], list), \ + f"deps field is not an array: {type(data['deps'])}" + return + raise AssertionError("No JSONL line with deps field found") + + +@then(r'the dep tree output should have (?P<first_id>[^\s]+) before (?P<second_id>[^\s]+)') +def step_dep_tree_order(context, first_id, second_id): + """Assert that first_id appears before second_id in dep 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, \ + f"Expected '{first_id}' (line {first_line + 1}) before '{second_id}' (line {second_line + 1})\nOutput:\n{output}" diff --git a/features/ticket_creation.feature b/features/ticket_creation.feature new file mode 100644 index 00000000..7cc07768 --- /dev/null +++ b/features/ticket_creation.feature @@ -0,0 +1,98 @@ +Feature: Ticket Creation + As a user + I want to create tickets with various options + So that I can track tasks in my project + + Background: + Given a clean tickets directory + + Scenario: Create a basic ticket with title + When I run "ticket create 'My first ticket'" + Then the command should succeed + And the output should match a ticket ID pattern + And a ticket file should exist with title "My first ticket" + + Scenario: Create a ticket with default title + When I run "ticket create" + Then the command should succeed + And the output should match a ticket ID pattern + And a ticket file should exist with title "Untitled" + + Scenario: Create a ticket with description + When I run "ticket create 'Test ticket' -d 'This is the description'" + Then the command should succeed + And the created ticket should contain "This is the description" + + Scenario: Create a ticket with type + When I run "ticket create 'Bug ticket' -t bug" + Then the command should succeed + And the created ticket should have field "type" with value "bug" + + Scenario: Create a ticket with priority + When I run "ticket create 'High priority' -p 0" + Then the command should succeed + And the created ticket should have field "priority" with value "0" + + Scenario: Create a ticket with assignee + When I run "ticket create 'Assigned ticket' -a 'John Doe'" + Then the command should succeed + And the created ticket should have field "assignee" with value "John Doe" + + Scenario: Create a ticket with external reference + When I run "ticket create 'External ticket' --external-ref 'JIRA-123'" + Then the command should succeed + And the created ticket should have field "external-ref" with value "JIRA-123" + + Scenario: Create a ticket with parent + Given a ticket exists with ID "parent-001" and title "Parent ticket" + When I run "ticket create 'Child ticket' --parent parent-001" + Then the command should succeed + And the created ticket should have field "parent" with value "parent-001" + + Scenario: Create a ticket with design notes + When I run "ticket create 'Design ticket' --design 'Use microservices'" + Then the command should succeed + And the created ticket should contain "## Design" + And the created ticket should contain "Use microservices" + + Scenario: Create a ticket with acceptance criteria + When I run "ticket create 'Story ticket' --acceptance 'Should pass all tests'" + Then the command should succeed + And the created ticket should contain "## Acceptance Criteria" + And the created ticket should contain "Should pass all tests" + + Scenario: Ticket has default status open + When I run "ticket create 'New ticket'" + Then the command should succeed + And the created ticket should have field "status" with value "open" + + Scenario: Ticket has default priority 2 + When I run "ticket create 'Normal priority'" + Then the command should succeed + And the created ticket should have field "priority" with value "2" + + Scenario: Ticket has default type task + When I run "ticket create 'Default type'" + Then the command should succeed + And the created ticket should have field "type" with value "task" + + Scenario: Ticket has empty deps by default + When I run "ticket create 'No deps'" + Then the command should succeed + And the created ticket should have field "deps" with value "[]" + + Scenario: Ticket has empty links by default + When I run "ticket create 'No links'" + Then the command should succeed + And the created ticket should have field "links" with value "[]" + + Scenario: Ticket has created timestamp + When I run "ticket create 'Timestamped'" + Then the command should succeed + And the created ticket should have a valid created timestamp + + Scenario: Tickets directory created on demand + Given the tickets directory does not exist + When I run "ticket create 'First ticket'" + Then the command should succeed + And the tickets directory should exist diff --git a/features/ticket_dependencies.feature b/features/ticket_dependencies.feature new file mode 100644 index 00000000..c2dc21b5 --- /dev/null +++ b/features/ticket_dependencies.feature @@ -0,0 +1,145 @@ +Feature: Ticket Dependencies + As a user + I want to manage ticket dependencies + So that I can track blocking relationships + + Background: + Given a clean tickets directory + And a ticket exists with ID "task-0001" and title "Main task" + And a ticket exists with ID "task-0002" and title "Dependency task" + And a ticket exists with ID "task-0003" and title "Another task" + + Scenario: Add a dependency + When I run "ticket dep task-0001 task-0002" + Then the command should succeed + And the output should be "Added dependency: task-0001 -> task-0002" + And ticket "task-0001" should have "task-0002" in deps + + Scenario: Add dependency is idempotent + Given ticket "task-0001" depends on "task-0002" + When I run "ticket dep task-0001 task-0002" + Then the command should succeed + And the output should be "Dependency already exists" + + Scenario: Remove a dependency + Given ticket "task-0001" depends on "task-0002" + When I run "ticket undep task-0001 task-0002" + Then the command should succeed + And the output should be "Removed dependency: task-0001 -/-> task-0002" + And ticket "task-0001" should not have "task-0002" in deps + + Scenario: Remove non-existent dependency + When I run "ticket undep task-0001 task-0002" + Then the command should fail + And the output should be "Dependency not found" + + Scenario: Add dependency with non-existent ticket + When I run "ticket dep task-0001 nonexistent" + Then the command should fail + And the output should contain "Error: ticket 'nonexistent' not found" + + Scenario: Add dependency to non-existent ticket + When I run "ticket dep nonexistent task-0001" + Then the command should fail + And the output should contain "Error: ticket 'nonexistent' not found" + + Scenario: View dependency tree + Given ticket "task-0001" depends on "task-0002" + And ticket "task-0002" depends on "task-0003" + When I run "ticket dep tree task-0001" + Then the command should succeed + And the output should contain "task-0001" + And the output should contain "task-0002" + And the output should contain "task-0003" + + Scenario: Dependency tree shows status and title + Given ticket "task-0001" depends on "task-0002" + When I run "ticket dep tree task-0001" + Then the command should succeed + And the output should contain "[open]" + And the output should contain "Main task" + And the output should contain "Dependency task" + + Scenario: Dependency tree uses box-drawing characters + Given ticket "task-0001" depends on "task-0002" + When I run "ticket dep tree task-0001" + Then the command should succeed + And the output should match box-drawing tree format + + Scenario: Dependency tree with multiple children + Given ticket "task-0001" depends on "task-0002" + And ticket "task-0001" depends on "task-0003" + When I run "ticket dep tree task-0001" + Then the command should succeed + And the output should contain "task-0002" + And the output should contain "task-0003" + + Scenario: Dependency tree handles cycles gracefully + Given ticket "task-0001" depends on "task-0002" + And ticket "task-0002" depends on "task-0001" + When I run "ticket dep tree task-0001" + Then the command should succeed + And the output should contain "task-0001" + And the output should contain "task-0002" + + Scenario: Full dependency tree shows all occurrences + Given ticket "task-0001" depends on "task-0002" + And ticket "task-0001" depends on "task-0003" + And ticket "task-0002" depends on "task-0003" + When I run "ticket dep tree --full task-0001" + Then the command should succeed + + Scenario: Dependency tree children sorted by subtree depth then ID + Given a ticket exists with ID "task-0001" and title "Root" + And a ticket exists with ID "task-0002" and title "Child B shallow" + And a ticket exists with ID "task-0003" and title "Child A shallow" + And a ticket exists with ID "task-0004" and title "Child C deep" + And a ticket exists with ID "task-0005" and title "Grandchild" + And ticket "task-0001" depends on "task-0002" + And ticket "task-0001" depends on "task-0003" + And ticket "task-0001" depends on "task-0004" + And ticket "task-0004" depends on "task-0005" + When I run "ticket dep tree task-0001" + Then the command should succeed + And the dep tree output should have task-0002 before task-0003 + And the dep tree output should have task-0003 before task-0004 + And the dep tree output should have task-0002 before task-0004 + + Scenario: Dependency tree children sorted by ID when same depth + Given a ticket exists with ID "task-0001" and title "Root" + And a ticket exists with ID "task-0005" and title "Child E" + And a ticket exists with ID "task-0002" and title "Child B" + And a ticket exists with ID "task-0004" and title "Child D" + And a ticket exists with ID "task-0003" and title "Child C" + And ticket "task-0001" depends on "task-0005" + And ticket "task-0001" depends on "task-0002" + And ticket "task-0001" depends on "task-0004" + And ticket "task-0001" depends on "task-0003" + When I run "ticket dep tree task-0001" + Then the command should succeed + And the dep tree output should have task-0002 before task-0003 + And the dep tree output should have task-0003 before task-0004 + And the dep tree output should have task-0004 before task-0005 + + Scenario: Dependency tree complex multi-level sorting + Given a ticket exists with ID "task-0001" and title "Root" + And a ticket exists with ID "task-0010" and title "Shallow C" + And a ticket exists with ID "task-0005" and title "Shallow A" + And a ticket exists with ID "task-0008" and title "Shallow B" + And a ticket exists with ID "task-0020" and title "Deep B" + And a ticket exists with ID "task-0015" and title "Deep A" + And a ticket exists with ID "task-0025" and title "Deepest" + And ticket "task-0001" depends on "task-0010" + And ticket "task-0001" depends on "task-0005" + And ticket "task-0001" depends on "task-0008" + And ticket "task-0001" depends on "task-0020" + And ticket "task-0001" depends on "task-0015" + And ticket "task-0020" depends on "task-0025" + And ticket "task-0015" depends on "task-0025" + When I run "ticket dep tree task-0001" + Then the command should succeed + And the dep tree output should have task-0005 before task-0008 + And the dep tree output should have task-0008 before task-0010 + And the dep tree output should have task-0010 before task-0015 + And the dep tree output should have task-0010 before task-0020 + And the dep tree output should have task-0015 before task-0020 diff --git a/features/ticket_edit.feature b/features/ticket_edit.feature new file mode 100644 index 00000000..cd7569bf --- /dev/null +++ b/features/ticket_edit.feature @@ -0,0 +1,24 @@ +Feature: Ticket Edit + As a user + I want to edit tickets in my editor + So that I can make complex changes easily + + Background: + Given a clean tickets directory + And a ticket exists with ID "edit-0001" and title "Editable ticket" + + Scenario: Edit in non-TTY mode shows file path + When I run "ticket edit edit-0001" in non-TTY mode + Then the command should succeed + And the output should contain "Edit ticket file:" + And the output should contain ".tickets/edit-0001.md" + + Scenario: Edit non-existent ticket + When I run "ticket edit nonexistent" + Then the command should fail + And the output should contain "Error: ticket 'nonexistent' not found" + + Scenario: Edit with partial ID + When I run "ticket edit 0001" in non-TTY mode + Then the command should succeed + And the output should contain "edit-0001.md" diff --git a/features/ticket_links.feature b/features/ticket_links.feature new file mode 100644 index 00000000..c4cf43af --- /dev/null +++ b/features/ticket_links.feature @@ -0,0 +1,58 @@ +Feature: Ticket Links + As a user + I want to create symmetric links between tickets + So that I can track related tickets + + Background: + Given a clean tickets directory + And a ticket exists with ID "link-0001" and title "First ticket" + And a ticket exists with ID "link-0002" and title "Second ticket" + And a ticket exists with ID "link-0003" and title "Third ticket" + + Scenario: Link two tickets + When I run "ticket link link-0001 link-0002" + Then the command should succeed + And the output should contain "Added 2 link(s) between 2 tickets" + And ticket "link-0001" should have "link-0002" in links + And ticket "link-0002" should have "link-0001" in links + + Scenario: Link three tickets + When I run "ticket link link-0001 link-0002 link-0003" + Then the command should succeed + And the output should contain "Added 6 link(s) between 3 tickets" + And ticket "link-0001" should have "link-0002" in links + And ticket "link-0001" should have "link-0003" in links + And ticket "link-0002" should have "link-0001" in links + And ticket "link-0002" should have "link-0003" in links + And ticket "link-0003" should have "link-0001" in links + And ticket "link-0003" should have "link-0002" in links + + Scenario: Link is idempotent + Given ticket "link-0001" is linked to "link-0002" + When I run "ticket link link-0001 link-0002" + Then the command should succeed + And the output should be "All links already exist" + + Scenario: Unlink two tickets + Given ticket "link-0001" is linked to "link-0002" + When I run "ticket unlink link-0001 link-0002" + Then the command should succeed + And the output should be "Removed link: link-0001 <-> link-0002" + And ticket "link-0001" should not have "link-0002" in links + And ticket "link-0002" should not have "link-0001" in links + + Scenario: Unlink non-existent link + When I run "ticket unlink link-0001 link-0002" + Then the command should fail + And the output should be "Link not found" + + Scenario: Link with non-existent ticket + When I run "ticket link link-0001 nonexistent" + Then the command should fail + And the output should contain "Error: ticket 'nonexistent' not found" + + Scenario: Partial linking adds only new links + Given ticket "link-0001" is linked to "link-0002" + When I run "ticket link link-0001 link-0002 link-0003" + Then the command should succeed + And the output should contain "Added 4 link(s) between 3 tickets" diff --git a/features/ticket_listing.feature b/features/ticket_listing.feature new file mode 100644 index 00000000..3873b39b --- /dev/null +++ b/features/ticket_listing.feature @@ -0,0 +1,157 @@ +Feature: Ticket Listing + As a user + I want to list tickets in various ways + So that I can see what work needs to be done + + Background: + Given a clean tickets directory + + Scenario: List all tickets + Given a ticket exists with ID "list-0001" and title "First ticket" + And a ticket exists with ID "list-0002" and title "Second ticket" + When I run "ticket ls" + Then the command should succeed + And the output should contain "list-0001" + And the output should contain "list-0002" + + Scenario: List shows ticket format correctly + Given a ticket exists with ID "list-0001" and title "My ticket" + When I run "ticket ls" + Then the command should succeed + And the output should match pattern "list-0001\s+\[open\]\s+-\s+My ticket" + + Scenario: List with status filter + Given a ticket exists with ID "list-0001" and title "Open ticket" + And a ticket exists with ID "list-0002" and title "Closed ticket" + And ticket "list-0002" has status "closed" + When I run "ticket ls --status=open" + Then the command should succeed + And the output should contain "list-0001" + And the output should not contain "list-0002" + + Scenario: List shows dependencies + Given a ticket exists with ID "list-0001" and title "Main ticket" + And a ticket exists with ID "list-0002" and title "Dep ticket" + And ticket "list-0001" depends on "list-0002" + When I run "ticket ls" + Then the command should succeed + And the output should contain "<- [list-0002]" + + Scenario: List with no tickets returns nothing + When I run "ticket ls" + Then the output should be empty + + Scenario: Ready shows tickets with no deps and with closed deps + Given a ticket exists with ID "ready-001" and title "Ready ticket" + And a ticket exists with ID "ready-002" and title "Unblocked ticket" + And a ticket exists with ID "ready-003" and title "Dependency" + And ticket "ready-002" depends on "ready-003" + And ticket "ready-003" has status "closed" + When I run "ticket ready" + Then the command should succeed + And the output should contain "ready-001" + And the output should contain "ready-002" + + Scenario: Ready excludes tickets with unclosed deps + Given a ticket exists with ID "ready-001" and title "Blocked ticket" + And a ticket exists with ID "ready-002" and title "Open dependency" + And ticket "ready-001" depends on "ready-002" + When I run "ticket ready" + Then the command should succeed + And the output should not contain "ready-001" + And the output should contain "ready-002" + + Scenario: Ready shows tickets when deps are closed + Given a ticket exists with ID "ready-001" and title "Main ticket" + And a ticket exists with ID "ready-002" and title "Closed dependency" + And ticket "ready-001" depends on "ready-002" + And ticket "ready-002" has status "closed" + When I run "ticket ready" + Then the command should succeed + And the output should contain "ready-001" + + Scenario: Ready excludes closed tickets + Given a ticket exists with ID "ready-001" and title "Closed ticket" + And ticket "ready-001" has status "closed" + When I run "ticket ready" + Then the command should succeed + And the output should not contain "ready-001" + + Scenario: Ready shows priority in output + Given a ticket exists with ID "ready-001" and title "Priority ticket" + When I run "ticket ready" + Then the command should succeed + And the output should match pattern "ready-001\s+\[P2\]\[open\]\s+-\s+Priority ticket" + + Scenario: Ready sorts by priority then ID + Given a ticket exists with ID "ready-003" and title "Low priority" with priority 3 + And a ticket exists with ID "ready-001" and title "High priority" with priority 1 + And a ticket exists with ID "ready-002" and title "Also high priority" with priority 1 + When I run "ticket ready" + Then the command should succeed + And the output line 1 should contain "ready-001" + And the output line 2 should contain "ready-002" + And the output line 3 should contain "ready-003" + + Scenario: Blocked shows tickets with unclosed deps + Given a ticket exists with ID "block-001" and title "Blocked ticket" + And a ticket exists with ID "block-002" and title "Blocker ticket" + And ticket "block-001" depends on "block-002" + When I run "ticket blocked" + Then the command should succeed + And the output should contain "block-001" + And the output should contain "<- [block-002]" + + Scenario: Blocked excludes tickets with all deps closed + Given a ticket exists with ID "block-001" and title "Unblocked ticket" + And a ticket exists with ID "block-002" and title "Closed blocker" + And ticket "block-001" depends on "block-002" + And ticket "block-002" has status "closed" + When I run "ticket blocked" + Then the command should succeed + And the output should not contain "block-001" + + Scenario: Blocked excludes closed tickets + Given a ticket exists with ID "block-001" and title "Closed blocked" + And a ticket exists with ID "block-002" and title "Blocker" + And ticket "block-001" depends on "block-002" + And ticket "block-001" has status "closed" + When I run "ticket blocked" + Then the command should succeed + And the output should not contain "block-001" + + Scenario: Blocked shows only unclosed blockers + Given a ticket exists with ID "block-001" and title "Blocked ticket" + And a ticket exists with ID "block-002" and title "Open blocker" + And a ticket exists with ID "block-003" and title "Closed blocker" + And ticket "block-001" depends on "block-002" + And ticket "block-001" depends on "block-003" + And ticket "block-003" has status "closed" + When I run "ticket blocked" + Then the command should succeed + And the output should contain "<- [block-002]" + And the output should not contain "block-003" + + Scenario: Closed shows recently 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 closed" + Then the command should succeed + And the output should contain "done-0001" + And the output should contain "[closed]" + And the output should contain "Done ticket" + + Scenario: Closed respects limit + Given a ticket exists with ID "done-0001" and title "First done" + And a ticket exists with ID "done-0002" and title "Second done" + And ticket "done-0001" has status "closed" + And ticket "done-0002" has status "closed" + When I run "ticket closed --limit=1" + Then the command should succeed + And the output line count should be 1 + + Scenario: Closed excludes open tickets + Given a ticket exists with ID "done-0001" and title "Open ticket" + When I run "ticket closed" + Then the command should succeed + And the output should not contain "done-0001" diff --git a/features/ticket_notes.feature b/features/ticket_notes.feature new file mode 100644 index 00000000..720afe5a --- /dev/null +++ b/features/ticket_notes.feature @@ -0,0 +1,48 @@ +Feature: Ticket Notes + As a user + I want to add notes to tickets + So that I can track progress and updates + + Background: + Given a clean tickets directory + And a ticket exists with ID "note-0001" and title "Test ticket" + + Scenario: Add a note to ticket + When I run "ticket add-note note-0001 'This is my note'" + Then the command should succeed + And the output should be "Note added to note-0001" + And ticket "note-0001" should contain "## Notes" + And ticket "note-0001" should contain "This is my note" + + Scenario: Note has timestamp + When I run "ticket add-note note-0001 'Timestamped note'" + Then the command should succeed + And ticket "note-0001" should contain a timestamp in notes + + Scenario: Add multiple notes + When I run "ticket add-note note-0001 'First note'" + And I run "ticket add-note note-0001 'Second note'" + Then ticket "note-0001" should contain "First note" + And ticket "note-0001" should contain "Second note" + + Scenario: Add note to ticket that already has notes section + Given ticket "note-0001" has a notes section + When I run "ticket add-note note-0001 'Additional note'" + Then the command should succeed + And ticket "note-0001" should contain "Additional note" + + Scenario: Add note with empty string adds timestamp-only note + When I run "ticket add-note note-0001 ''" + Then the command should succeed + And the output should be "Note added to note-0001" + And ticket "note-0001" should contain "## Notes" + + Scenario: Add note to non-existent ticket + When I run "ticket add-note nonexistent 'My note'" + Then the command should fail + And the output should contain "Error: ticket 'nonexistent' not found" + + Scenario: Add note with partial ID + When I run "ticket add-note 0001 'Partial ID note'" + Then the command should succeed + And the output should be "Note added to note-0001" diff --git a/features/ticket_query.feature b/features/ticket_query.feature new file mode 100644 index 00000000..2ccafe70 --- /dev/null +++ b/features/ticket_query.feature @@ -0,0 +1,48 @@ +Feature: Ticket Query + As a user + I want to query tickets as JSON + So that I can process ticket data programmatically + + Background: + Given a clean tickets directory + + Scenario: Query all tickets as JSONL + Given a ticket exists with ID "query-001" and title "First ticket" + And a ticket exists with ID "query-002" and title "Second ticket" + When I run "ticket query" + Then the command should succeed + And the output should be valid JSONL + And the output should contain "query-001" + And the output should contain "query-002" + + Scenario: Query with jq filter + Given a ticket exists with ID "query-001" and title "Open ticket" + And a ticket exists with ID "query-002" and title "Closed ticket" + And ticket "query-002" has status "closed" + When I run "ticket query '.status == \"open\"'" + Then the command should succeed + And the output should contain "query-001" + And the output should not contain "query-002" + + Scenario: Query includes all fields + Given a ticket exists with ID "query-001" and title "Full ticket" + When I run "ticket query" + Then the command should succeed + And the JSONL output should have field "id" + And the JSONL output should have field "status" + And the JSONL output should have field "deps" + And the JSONL output should have field "links" + And the JSONL output should have field "type" + And the JSONL output should have field "priority" + + Scenario: Query with no tickets + When I run "ticket query" + Then the output should be empty + + Scenario: Query arrays are JSON arrays + Given a ticket exists with ID "query-001" and title "Deps ticket" + And a ticket exists with ID "query-002" and title "Dependency" + And ticket "query-001" depends on "query-002" + When I run "ticket query" + Then the command should succeed + And the JSONL deps field should be a JSON array diff --git a/features/ticket_show.feature b/features/ticket_show.feature new file mode 100644 index 00000000..730977be --- /dev/null +++ b/features/ticket_show.feature @@ -0,0 +1,87 @@ +Feature: Ticket Show + As a user + I want to view ticket details + So that I can see full information about a ticket + + Background: + Given a clean tickets directory + + Scenario: Show displays ticket content + Given a ticket exists with ID "show-001" and title "Test ticket" + When I run "ticket show show-001" + Then the command should succeed + And the output should contain "id: show-001" + And the output should contain "# Test ticket" + + Scenario: Show displays all frontmatter fields + Given a ticket exists with ID "show-001" and title "Full ticket" + When I run "ticket show show-001" + Then the command should succeed + And the output should contain "status: open" + And the output should contain "deps: []" + And the output should contain "links: []" + And the output should contain "type: task" + And the output should contain "priority: 2" + + Scenario: Show displays blockers section + Given a ticket exists with ID "show-001" and title "Blocked ticket" + And a ticket exists with ID "show-002" and title "Blocker ticket" + And ticket "show-001" depends on "show-002" + When I run "ticket show show-001" + Then the command should succeed + And the output should contain "## Blockers" + And the output should contain "show-002 [open] Blocker ticket" + + Scenario: Show hides blockers section when all deps closed + Given a ticket exists with ID "show-001" and title "Unblocked ticket" + And a ticket exists with ID "show-002" and title "Closed blocker" + And ticket "show-001" depends on "show-002" + And ticket "show-002" has status "closed" + When I run "ticket show show-001" + Then the command should succeed + And the output should not contain "## Blockers" + + Scenario: Show displays blocking section + Given a ticket exists with ID "show-001" and title "Blocker" + And a ticket exists with ID "show-002" and title "Blocked" + And ticket "show-002" depends on "show-001" + When I run "ticket show show-001" + Then the command should succeed + And the output should contain "## Blocking" + And the output should contain "show-002 [open] Blocked" + + Scenario: Show displays children section + Given a ticket exists with ID "show-001" and title "Parent" + And a ticket exists with ID "show-002" and title "Child" with parent "show-001" + When I run "ticket show show-001" + Then the command should succeed + And the output should contain "## Children" + And the output should contain "show-002 [open] Child" + + Scenario: Show displays linked section + Given a ticket exists with ID "show-001" and title "First" + And a ticket exists with ID "show-002" and title "Second" + And ticket "show-001" is linked to "show-002" + When I run "ticket show show-001" + Then the command should succeed + And the output should contain "## Linked" + And the output should contain "show-002 [open] Second" + + Scenario: Show enhances parent field with title + Given a ticket exists with ID "show-001" and title "Parent ticket" + And a ticket exists with ID "show-002" and title "Child ticket" with parent "show-001" + When I run "ticket show show-002" + Then the command should succeed + And the output should contain "parent: show-001" + And the output should contain "# Parent ticket" + + Scenario: Show non-existent ticket + When I run "ticket show nonexistent" + Then the command should fail + And the output should contain "Error: ticket 'nonexistent' not found" + + Scenario: Show with partial ID + Given a ticket exists with ID "show-001" and title "Test ticket" + When I run "ticket show 001" + Then the command should succeed + And the output should contain "id: show-001" diff --git a/features/ticket_status.feature b/features/ticket_status.feature new file mode 100644 index 00000000..c625fd23 --- /dev/null +++ b/features/ticket_status.feature @@ -0,0 +1,62 @@ +Feature: Ticket Status Management + As a user + I want to change ticket statuses + So that I can track progress on tasks + + Background: + Given a clean tickets directory + And a ticket exists with ID "test-0001" and title "Test ticket" + + Scenario: Set status to in_progress + When I run "ticket status test-0001 in_progress" + Then the command should succeed + And the output should be "Updated test-0001 -> in_progress" + And ticket "test-0001" should have field "status" with value "in_progress" + + Scenario: Set status to closed + When I run "ticket status test-0001 closed" + Then the command should succeed + And the output should be "Updated test-0001 -> closed" + And ticket "test-0001" should have field "status" with value "closed" + + Scenario: Set status to open + Given ticket "test-0001" has status "closed" + When I run "ticket status test-0001 open" + Then the command should succeed + And the output should be "Updated test-0001 -> open" + And ticket "test-0001" should have field "status" with value "open" + + Scenario: Start command sets status to in_progress + When I run "ticket start test-0001" + Then the command should succeed + And the output should be "Updated test-0001 -> in_progress" + And ticket "test-0001" should have field "status" with value "in_progress" + + Scenario: Close command sets status to closed + When I run "ticket close test-0001" + Then the command should succeed + And the output should be "Updated test-0001 -> closed" + And ticket "test-0001" should have field "status" with value "closed" + + Scenario: Reopen command sets status to open + Given ticket "test-0001" has status "closed" + When I run "ticket reopen test-0001" + Then the command should succeed + And the output should be "Updated test-0001 -> open" + And ticket "test-0001" should have field "status" with value "open" + + Scenario: Invalid status value + When I run "ticket status test-0001 invalid" + Then the command should fail + And the output should contain "Error: invalid status 'invalid'" + And the output should contain "open in_progress closed" + + Scenario: Status of non-existent ticket + When I run "ticket status nonexistent open" + Then the command should fail + And the output should contain "Error: ticket 'nonexistent' not found" + + Scenario: Status command with partial ID + When I run "ticket status 0001 in_progress" + Then the command should succeed + And ticket "test-0001" should have field "status" with value "in_progress" From e648fd4e22a20451e3960dd158bf028a3e301af6 Mon Sep 17 00:00:00 2001 From: Ray Myers <ray.myers@gmail.com> Date: Fri, 16 Jan 2026 18:59:43 -0600 Subject: [PATCH 2/2] Add test ci --- .github/workflows/test.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..c7ac205c --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,17 @@ +name: Test + +on: + push: + branches: [master] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v4 + + - name: Run tests + run: make test