From c663b3c8a137013dc6eb4807aa27ad3a54bf167b Mon Sep 17 00:00:00 2001 From: tryeverything24 <114252040+tryeverything24@users.noreply.github.com> Date: Sat, 25 Jul 2026 02:32:26 +0300 Subject: [PATCH] fix(cli): report the real reason gitt miner post rejects a PAT _validate_pat_locally() collapses three different failures into a bare None, so miner_post always reports "GitHub PAT is invalid or expired": - A PAT that authenticates fine on /user but fails the GraphQL probe (the default for GitHub's fine-grained PATs without "Public Repositories (read-only)") printed the true cause to stderr only, then still exited with the generic invalid-or-expired verdict. In --json mode the machine-readable error on stdout carried only the wrong cause, telling automation to rotate a token that works. - A network failure reaching GitHub (requests.RequestException) was also reported as an invalid PAT, sending operators off to mint a new token when the fix is to retry. Return (login, reason) from _validate_pat_locally() and surface the specific reason through _error() in both human and --json output. The same verdict-vs-transient distinction was applied to issue submissions in #1554; this applies it to the PAT broadcast path. Update the mocked call sites and add regression tests for the three failure causes plus the --json envelope. --- gittensor/cli/miner_commands/post.py | 28 +++++----- tests/cli/test_miner_commands.py | 80 ++++++++++++++++++++++++++-- 2 files changed, 92 insertions(+), 16 deletions(-) diff --git a/gittensor/cli/miner_commands/post.py b/gittensor/cli/miner_commands/post.py index 233ce2866..6f3c73b3a 100644 --- a/gittensor/cli/miner_commands/post.py +++ b/gittensor/cli/miner_commands/post.py @@ -29,7 +29,6 @@ _resolve_endpoint, _status, console, - err_console, ) from gittensor.constants import BASE_GITHUB_API_URL, GITHUB_HTTP_TIMEOUT_SECONDS, GRAPHQL_VIEWER_QUERY from gittensor.utils.github_api_tools import make_graphql_headers, make_headers @@ -96,10 +95,10 @@ def miner_post(wallet_name, wallet_hotkey, netuid, network, rpc_url, pat, min_vt # 1b. Validate PAT locally with _status('[bold]Validating PAT...'): - github_login = _validate_pat_locally(pat) + github_login, pat_error = _validate_pat_locally(pat) if github_login is None: - _error('GitHub PAT is invalid or expired. Check your GITTENSOR_MINER_PAT.', json_mode) + _error(pat_error or 'GitHub PAT is invalid or expired. Check your GITTENSOR_MINER_PAT.', json_mode) sys.exit(1) _print(f'[green]PAT is valid.[/green] GitHub account: [bold]@{github_login}[/bold]') @@ -192,10 +191,14 @@ async def _broadcast(): _render_skipped_validators(excluded, json_mode) -def _validate_pat_locally(pat: str) -> str | None: +def _validate_pat_locally(pat: str) -> tuple[str | None, str | None]: """Validate PAT mirrors the validator-side checks: user identity + GraphQL access. - Returns the GitHub login on success, or None if the PAT is invalid. + Returns ``(login, None)`` on success, or ``(None, reason)`` where ``reason`` + states why validation failed. The three failure causes need distinct + messages: an invalid/expired token, a token that authenticates but lacks + GraphQL access (typical for fine-grained PATs), and a network failure + reaching GitHub (the PAT itself may be fine — retry, don't rotate). """ try: # Check basic auth and extract login @@ -203,8 +206,10 @@ def _validate_pat_locally(pat: str) -> str | None: f'{BASE_GITHUB_API_URL}/user', headers=make_headers(pat), timeout=GITHUB_HTTP_TIMEOUT_SECONDS ) if user_resp.status_code != 200: - return None + return None, 'GitHub PAT is invalid or expired. Check your GITTENSOR_MINER_PAT.' login: str | None = user_resp.json().get('login') or None + if login is None: + return None, 'GitHub PAT is invalid or expired. Check your GITTENSOR_MINER_PAT.' # Check GraphQL access (same test the validator runs during PAT broadcast) gql_resp = requests.post( @@ -214,11 +219,10 @@ def _validate_pat_locally(pat: str) -> str | None: timeout=GITHUB_HTTP_TIMEOUT_SECONDS, ) if gql_resp.status_code != 200: - err_console.print( - '[red]PAT lacks GraphQL API access. Fine-grained PATs need "Public Repositories (read-only)" permission.[/red]' + return None, ( + 'PAT lacks GraphQL API access. Fine-grained PATs need "Public Repositories (read-only)" permission.' ) - return None - return login - except requests.RequestException: - return None + return login, None + except requests.RequestException as e: + return None, f'Could not reach GitHub to validate the PAT ({e}). Check your connection and retry.' diff --git a/tests/cli/test_miner_commands.py b/tests/cli/test_miner_commands.py index d93395c9e..dc94a8c18 100644 --- a/tests/cli/test_miner_commands.py +++ b/tests/cli/test_miner_commands.py @@ -7,6 +7,7 @@ from unittest.mock import patch import pytest +import requests from click.testing import CliRunner from gittensor import __version__ @@ -19,6 +20,7 @@ _require_validator_axons, _resolve_endpoint, ) +from gittensor.cli.miner_commands.post import _validate_pat_locally from gittensor.constants import NETWORK_MAP @@ -40,7 +42,10 @@ def runner(): class TestMinerPost: @patch('gittensor.cli.miner_commands.post.click.prompt', return_value='ghp_fake') - @patch('gittensor.cli.miner_commands.post._validate_pat_locally', return_value=None) + @patch( + 'gittensor.cli.miner_commands.post._validate_pat_locally', + return_value=(None, 'GitHub PAT is invalid or expired. Check your GITTENSOR_MINER_PAT.'), + ) def test_no_pat_prompts_interactively(self, mock_validate, mock_prompt, runner, monkeypatch): monkeypatch.delenv('GITTENSOR_MINER_PAT', raising=False) runner.invoke(cli, ['miner', 'post', '--wallet', 'test', '--hotkey', 'test']) @@ -53,7 +58,10 @@ def test_no_pat_json_mode_exits(self, runner, monkeypatch): output = json.loads(result.stdout) assert output['success'] is False - @patch('gittensor.cli.miner_commands.post._validate_pat_locally', return_value=None) + @patch( + 'gittensor.cli.miner_commands.post._validate_pat_locally', + return_value=(None, 'GitHub PAT is invalid or expired. Check your GITTENSOR_MINER_PAT.'), + ) def test_pat_flag_used(self, mock_validate, runner, monkeypatch): monkeypatch.delenv('GITTENSOR_MINER_PAT', raising=False) result = runner.invoke(cli, ['miner', 'post', '--pat', 'ghp_test123', '--wallet', 'test', '--hotkey', 'test']) @@ -61,7 +69,10 @@ def test_pat_flag_used(self, mock_validate, runner, monkeypatch): assert 'invalid' in result.stderr.lower() or 'expired' in result.stderr.lower() mock_validate.assert_called_once_with('ghp_test123') - @patch('gittensor.cli.miner_commands.post._validate_pat_locally', return_value=None) + @patch( + 'gittensor.cli.miner_commands.post._validate_pat_locally', + return_value=(None, 'GitHub PAT is invalid or expired. Check your GITTENSOR_MINER_PAT.'), + ) def test_invalid_pat_exits(self, mock_validate, runner, monkeypatch): monkeypatch.setenv('GITTENSOR_MINER_PAT', 'ghp_invalid') result = runner.invoke(cli, ['miner', 'post', '--wallet', 'test', '--hotkey', 'test']) @@ -101,7 +112,7 @@ async def __call__(self, **kwargs): return responses with ( - patch('gittensor.cli.miner_commands.post._validate_pat_locally', return_value='testuser'), + patch('gittensor.cli.miner_commands.post._validate_pat_locally', return_value=('testuser', None)), patch( 'gittensor.cli.miner_commands.post._connect_bittensor', return_value=(wallet, object(), metagraph, FakeDendrite()), @@ -374,3 +385,64 @@ def test_no_response_is_not_collapsed_into_rejected(self): counts = _pat_post_aggregate_counts(results) assert counts['rejected'] == 1 assert counts['no_response'] == 1 + + +class TestValidatePatLocally: + """_validate_pat_locally must report WHY validation failed, not just fail. + + The three failure causes need distinct messages: an invalid/expired token, + a token that authenticates but lacks GraphQL access (typical for + fine-grained PATs), and a network failure reaching GitHub (retryable — + rotating the PAT won't help). + """ + + def test_invalid_token_reports_invalid(self): + with patch('gittensor.cli.miner_commands.post.requests') as mock: + mock.RequestException = requests.RequestException + mock.get.return_value = SimpleNamespace(status_code=401) + login, error = _validate_pat_locally('ghp_bad') + assert login is None + assert error is not None and 'invalid or expired' in error + + def test_missing_graphql_access_reports_graphql_cause(self): + with patch('gittensor.cli.miner_commands.post.requests') as mock: + mock.RequestException = requests.RequestException + mock.get.return_value = SimpleNamespace(status_code=200, json=lambda: {'login': 'someuser'}) + mock.post.return_value = SimpleNamespace(status_code=403) + login, error = _validate_pat_locally('github_pat_finegrained') + assert login is None + assert error is not None and 'GraphQL' in error + assert 'invalid or expired' not in error + + def test_network_failure_reports_connectivity_not_invalid(self): + with patch('gittensor.cli.miner_commands.post.requests') as mock: + mock.RequestException = requests.RequestException + mock.get.side_effect = requests.RequestException('connection refused') + login, error = _validate_pat_locally('ghp_fine') + assert login is None + assert error is not None and 'reach GitHub' in error + assert 'invalid or expired' not in error + + def test_valid_token_returns_login_and_no_error(self): + with patch('gittensor.cli.miner_commands.post.requests') as mock: + mock.RequestException = requests.RequestException + mock.get.return_value = SimpleNamespace(status_code=200, json=lambda: {'login': 'someuser'}) + mock.post.return_value = SimpleNamespace(status_code=200) + login, error = _validate_pat_locally('ghp_good') + assert login == 'someuser' + assert error is None + + def test_post_json_mode_surfaces_specific_reason(self, monkeypatch): + """The machine-readable --json error must carry the real cause, not a + generic 'invalid or expired' verdict for a PAT that authenticated fine.""" + monkeypatch.delenv('GITTENSOR_MINER_PAT', raising=False) + runner = CliRunner() + reason = 'PAT lacks GraphQL API access. Fine-grained PATs need "Public Repositories (read-only)" permission.' + with patch('gittensor.cli.miner_commands.post._validate_pat_locally', return_value=(None, reason)): + result = runner.invoke( + cli, ['miner', 'post', '--json', '--pat', 'github_pat_x', '--wallet', 'test', '--hotkey', 'test'] + ) + assert result.exit_code != 0 + output = json.loads(result.stdout) + assert output['success'] is False + assert 'GraphQL' in output['error']['message']