diff --git a/gittensor/cli/issue_commands/view.py b/gittensor/cli/issue_commands/view.py index 999c33f49..f9a33ee18 100644 --- a/gittensor/cli/issue_commands/view.py +++ b/gittensor/cli/issue_commands/view.py @@ -32,6 +32,7 @@ print_network_header, read_issues_from_contract, validate_issue_id, + validate_repository, with_cli_behavior_options, with_network_contract_options, ) @@ -88,6 +89,18 @@ def issues_list( except click.BadParameter as e: handle_exception(as_json, str(e), 'bad_parameter') + # Normalize and validate the --repo filter before any contract reads so + # malformed input is rejected up-front and whitespace-padded valid input + # still matches `repository_full_name` from the contract. validate_repository + # strips whitespace, enforces owner/name format, and raises + # click.BadParameter on bad input — same contract used by mutating commands. + if repo_filter is not None: + try: + owner, repo_name = validate_repository(repo_filter, verify_exists=False) + repo_filter = f'{owner}/{repo_name}' + except click.BadParameter as e: + handle_exception(as_json, str(e), 'bad_parameter') + contract_addr, ws_endpoint, network_name = _resolve_contract_and_network( contract, network, diff --git a/tests/cli/test_issues_list_json.py b/tests/cli/test_issues_list_json.py index a48e523c9..186d162ac 100644 --- a/tests/cli/test_issues_list_json.py +++ b/tests/cli/test_issues_list_json.py @@ -78,3 +78,111 @@ def test_issues_list_rejects_invalid_id_json(cli_root, runner, bad_id): assert payload['error']['type'] == 'bad_parameter' assert 'between 1 and 999999' in payload['error']['message'] mock_read.assert_not_called() + + +# --------------------------------------------------------------------------- +# --repo filter validation (regression for #1061) +# --------------------------------------------------------------------------- + + +def _parse_json_payload(output: str) -> dict: + """Extract and parse the JSON object from CLI output. + + Some success paths print a `Network: • Contract: ` header + before the JSON body, which trips json.loads on the raw output. Slice + from the first '{' so the parse is robust regardless of header presence. + """ + start = output.find('{') + if start < 0: + raise AssertionError(f'no JSON object found in output: {output[:200]!r}') + return json.loads(output[start:]) + + +@pytest.mark.parametrize('bad_repo', ['ownerrepo', 'owner//repo', 'owner/', '/repo', 'owner repo']) +def test_issues_list_rejects_malformed_repo_filter_json(cli_root, runner, bad_repo): + """Malformed --repo input must fail validation up-front before any contract read.""" + with patch('gittensor.cli.issue_commands.view.read_issues_from_contract') as mock_read: + result = runner.invoke( + cli_root, ['issues', 'list', '--json', '--repo', bad_repo], catch_exceptions=False + ) + + assert result.exit_code != 0 + payload = json.loads(result.output) + assert payload['success'] is False + assert payload['error']['type'] == 'bad_parameter' + mock_read.assert_not_called() + + +@pytest.mark.parametrize('bad_repo', ['ownerrepo', 'owner//repo']) +def test_issues_list_rejects_malformed_repo_filter_human(cli_root, runner, bad_repo): + """Human-mode --repo malformed input must also exit non-zero before contract read.""" + with patch('gittensor.cli.issue_commands.view.read_issues_from_contract') as mock_read: + result = runner.invoke(cli_root, ['issues', 'list', '--repo', bad_repo], catch_exceptions=False) + + assert result.exit_code != 0 + mock_read.assert_not_called() + + +def test_issues_list_repo_filter_strips_whitespace_json(cli_root, runner): + """Whitespace-padded valid --repo input must trim and match contract repository_full_name.""" + 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', '--repo', ' owner/repo '], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + payload = _parse_json_payload(result.output) + assert payload['success'] is True + assert payload['issue_count'] == 1 + assert payload['issues'][0]['repository_full_name'] == 'owner/repo' + + +def test_issues_list_repo_filter_case_insensitive_json(cli_root, runner): + """--repo filter should match contract entries regardless of case (preserved existing behavior).""" + 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', '--repo', 'OWNER/REPO'], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + payload = _parse_json_payload(result.output) + assert payload['success'] is True + assert payload['issue_count'] == 1 + + +def test_issues_list_repo_filter_no_match_returns_empty_json(cli_root, runner): + """Valid but non-matching --repo input returns empty list, not all issues.""" + 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', '--repo', 'other/repo'], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + payload = _parse_json_payload(result.output) + assert payload['success'] is True + assert payload['issue_count'] == 0 + assert payload['issues'] == []