Skip to content
Merged
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
25 changes: 15 additions & 10 deletions gittensor/cli/miner_commands/post.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,13 @@ def miner_post(wallet_name, wallet_hotkey, netuid, network, rpc_url, pat, min_vt

# 1b. Validate PAT locally
with _status('[bold]Validating PAT...'):
pat_valid = _validate_pat_locally(pat)
github_login = _validate_pat_locally(pat)

if not pat_valid:
if github_login is None:
_error('GitHub PAT is invalid or expired. Check your GITTENSOR_MINER_PAT.', json_mode)
sys.exit(1)

_print('[green]PAT is valid.[/green]')
_print(f'[green]PAT is valid.[/green] GitHub account: [bold]@{github_login}[/bold]')

# 2. Resolve wallet and network
wallet_name = wallet_name or _load_config_value('wallet') or 'default'
Expand Down Expand Up @@ -167,6 +167,7 @@ async def _broadcast():
json.dumps(
{
'success': accepted_count > 0,
'github_login': github_login,
'total_validators': len(results),
**counts,
'skipped': excluded,
Expand All @@ -192,15 +193,19 @@ async def _broadcast():
_render_skipped_validators(excluded, json_mode)


def _validate_pat_locally(pat: str) -> bool:
"""Validate PAT mirrors the validator-side checks: user identity + GraphQL access."""
def _validate_pat_locally(pat: str) -> 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.
"""
try:
# Check basic auth
# Check basic auth and extract login
user_resp = requests.get(
f'{BASE_GITHUB_API_URL}/user', headers=make_headers(pat), timeout=GITHUB_HTTP_TIMEOUT_SECONDS
)
if user_resp.status_code != 200:
return False
return None
login: str | None = user_resp.json().get('login') or None

# Check GraphQL access (same test the validator runs during PAT broadcast)
gql_resp = requests.post(
Expand All @@ -213,8 +218,8 @@ def _validate_pat_locally(pat: str) -> bool:
err_console.print(
'[red]PAT lacks GraphQL API access. Fine-grained PATs need "Public Repositories (read-only)" permission.[/red]'
)
return False
return None

return True
return login
except requests.RequestException:
return False
return None
19 changes: 11 additions & 8 deletions tests/cli/test_miner_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,12 @@ def runner():


class TestMinerPost:
def test_no_pat_prompts_interactively(self, runner, monkeypatch):
@patch('gittensor.cli.miner_commands.post.click.prompt', return_value='ghp_fake')
@patch('gittensor.cli.miner_commands.post._validate_pat_locally', return_value=None)
def test_no_pat_prompts_interactively(self, mock_validate, mock_prompt, runner, monkeypatch):
monkeypatch.delenv('GITTENSOR_MINER_PAT', raising=False)
result = runner.invoke(cli, ['miner', 'post', '--wallet', 'test', '--hotkey', 'test'], input='')
assert 'Enter your GitHub Personal Access Token' in result.output
runner.invoke(cli, ['miner', 'post', '--wallet', 'test', '--hotkey', 'test'])
mock_prompt.assert_called_once_with('Enter your GitHub Personal Access Token', hide_input=True)

def test_no_pat_json_mode_exits(self, runner, monkeypatch):
monkeypatch.delenv('GITTENSOR_MINER_PAT', raising=False)
Expand All @@ -49,20 +51,20 @@ 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=False)
@patch('gittensor.cli.miner_commands.post._validate_pat_locally', return_value=None)
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'])
assert result.exit_code != 0
assert 'invalid' in result.output.lower() or 'expired' in result.output.lower()
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=False)
@patch('gittensor.cli.miner_commands.post._validate_pat_locally', return_value=None)
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'])
assert result.exit_code != 0
assert 'invalid' in result.output.lower() or 'expired' in result.output.lower()
assert 'invalid' in result.stderr.lower() or 'expired' in result.stderr.lower()

def test_help_text(self, runner):
result = runner.invoke(cli, ['miner', 'post', '--help'])
Expand Down Expand Up @@ -97,7 +99,7 @@ async def __call__(self, **kwargs):
return responses

with (
patch('gittensor.cli.miner_commands.post._validate_pat_locally', return_value=True),
patch('gittensor.cli.miner_commands.post._validate_pat_locally', return_value='testuser'),
patch(
'gittensor.cli.miner_commands.post._connect_bittensor',
return_value=(wallet, object(), metagraph, FakeDendrite()),
Expand All @@ -120,6 +122,7 @@ async def __call__(self, **kwargs):

assert result.exit_code == 0, result.output
output = json.loads(result.stdout)
assert output['github_login'] == 'testuser'
assert output['total_validators'] == 3
assert output['accepted'] == 1
assert output['rejected'] == 1
Expand Down
Loading