diff --git a/gittensor/cli/issue_commands/helpers.py b/gittensor/cli/issue_commands/helpers.py index 6754b439b..a451e7762 100644 --- a/gittensor/cli/issue_commands/helpers.py +++ b/gittensor/cli/issue_commands/helpers.py @@ -60,6 +60,7 @@ CommandFunc = TypeVar('CommandFunc', bound=Callable[..., Any]) NETWORK_CHOICE = click.Choice(['finney', 'test', 'local'], case_sensitive=False) +STATUS_CHOICE = click.Choice(['registered', 'active', 'completed', 'cancelled'], case_sensitive=False) def apply_click_options(*decorators: Callable[[CommandFunc], CommandFunc]) -> Callable[[CommandFunc], CommandFunc]: diff --git a/gittensor/cli/issue_commands/view.py b/gittensor/cli/issue_commands/view.py index c890b42e1..c40add11c 100644 --- a/gittensor/cli/issue_commands/view.py +++ b/gittensor/cli/issue_commands/view.py @@ -21,6 +21,7 @@ from .help import StyledCommand from .helpers import ( + STATUS_CHOICE, _read_contract_packed_storage, _read_issues_from_child_storage, _resolve_contract_and_network, @@ -65,6 +66,13 @@ def _fill_percent(bounty: int, target: int) -> float: type=REPO, help='Filter issues to a specific repository (owner/name).', ) +@click.option( + '--status', + 'status_filter', + default=None, + type=STATUS_CHOICE, + help='Filter issues by lifecycle status (registered/active/completed/cancelled).', +) @with_cli_behavior_options( include_verbose=True, include_json=True, @@ -72,7 +80,14 @@ def _fill_percent(bounty: int, target: int) -> float: ) @with_network_contract_options('Contract address (uses default if empty)') def issues_list( - issue_id: int, repo_filter: str, network: str, rpc_url: str, contract: str, verbose: bool, as_json: bool + issue_id: int, + repo_filter: str, + status_filter: str, + network: str, + rpc_url: str, + contract: str, + verbose: bool, + as_json: bool, ): """List issues or view a specific issue. @@ -80,6 +95,7 @@ def issues_list( $ gitt issues list $ gitt i list --network test $ gitt i list --id 1 + $ gitt i list --status active $ gitt i list --json [/dim] """ @@ -114,6 +130,10 @@ def issues_list( if repo_filter and issue_id is None: issues = [i for i in issues if i.get('repository_full_name', '').lower() == repo_filter.lower()] + # Apply --status filter before rendering (--id takes precedence) + if status_filter and issue_id is None: + issues = [i for i in issues if str(i.get('status', '')).lower() == status_filter.lower()] + if issue_id is not None: issue = next((i for i in issues if i['id'] == issue_id), None) if issue is None: @@ -152,6 +172,10 @@ def issues_list( if repo_filter and issue_id is None: issues = [i for i in issues if i.get('repository_full_name', '').lower() == repo_filter.lower()] + # Apply --status filter before table render (--id takes precedence) + if status_filter and issue_id is None: + issues = [i for i in issues if str(i.get('status', '')).lower() == status_filter.lower()] + # Table view of all issues err_console.print('[bold cyan]Available Issues[/bold cyan]\n') diff --git a/tests/cli/test_issues_list_json.py b/tests/cli/test_issues_list_json.py index 0484e7b08..4e8be1318 100644 --- a/tests/cli/test_issues_list_json.py +++ b/tests/cli/test_issues_list_json.py @@ -17,6 +17,14 @@ 'target_bounty': 100_000_000_000, 'status': 'Active', }, + { + 'id': 2, + 'repository_full_name': 'owner/repo', + 'issue_number': 11, + 'bounty_amount': 0, + 'target_bounty': 100_000_000_000, + 'status': 'Completed', + }, ] @@ -66,6 +74,35 @@ def test_issues_list_rejects_invalid_id_human(cli_root, runner, bad_id): mock_read.assert_not_called() +def test_issues_list_json_status_filter_is_case_insensitive(cli_root, runner): + """`--status` must filter JSON output by lifecycle state regardless of input casing.""" + with ( + patch( + 'gittensor.cli.issue_commands.view._resolve_contract_and_network', + return_value=('5Fakeaddr', 'ws://x', 'test'), + ), + patch('gittensor.cli.issue_commands.view.read_issues_from_contract', return_value=FAKE_ISSUES), + ): + result = runner.invoke(cli_root, ['issues', 'list', '--json', '--status', 'ACTIVE'], catch_exceptions=False) + + assert result.exit_code == 0 + + payload = json.loads(result.stdout) + assert payload['success'] is True + assert payload['issue_count'] == 1 + assert [i['id'] for i in payload['issues']] == [1] + + +def test_issues_list_rejects_invalid_status(cli_root, runner): + """An unknown --status value must be rejected at parse time without any contract read.""" + with patch('gittensor.cli.issue_commands.view.read_issues_from_contract') as mock_read: + result = runner.invoke(cli_root, ['issues', 'list', '--status', 'bogus'], catch_exceptions=False) + + assert result.exit_code != 0 + assert 'bogus' in result.output + mock_read.assert_not_called() + + def test_issues_list_json_contract_read_failure_returns_structured_error(cli_root, runner): """A contract read failure must surface as `success: false` JSON with non-zero exit, not as a `success: true, issue_count: 0` payload that looks like a clean empty contract."""