Skip to content
Closed
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
79 changes: 73 additions & 6 deletions gittensor/utils/github_api_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@
query($userId: ID!, $limit: Int!, $cursor: String) {
node(id: $userId) {
... on User {
issues(states: [OPEN]) { totalCount }
pullRequests(first: $limit, states: [MERGED, OPEN, CLOSED], orderBy: {field: CREATED_AT, direction: DESC}, after: $cursor) {
pageInfo {
hasNextPage
Expand Down Expand Up @@ -942,6 +941,69 @@ def should_skip_merged_pr(
return (False, None)


_USER_OPEN_ISSUES_QUERY = """
query($userId: ID!, $cursor: String) {
node(id: $userId) {
... on User {
issues(states: [OPEN], first: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes { repository { nameWithOwner } }
}
}
}
}
"""


def count_tracked_open_issues(
token: str,
github_user_node_id: str,
tracked_repo_names: set,
max_pages: int = 10,
) -> Optional[int]:
"""Count open issues filed against tracked repos only.

Returns None on API failure so callers can fall back to 0 — a broken
fetch must not silently demote a miner via the spam multiplier.
"""
if not token or not tracked_repo_names:
return 0

# GitHub returns nameWithOwner in original case; normalize both sides.
tracked_lower = {name.lower() for name in tracked_repo_names}

count = 0
cursor: Optional[str] = None
for _ in range(max_pages):
result = execute_graphql_query(
query=_USER_OPEN_ISSUES_QUERY,
variables={'userId': github_user_node_id, 'cursor': cursor},
token=token,
max_attempts=3,
)
if not result or 'errors' in result:
bt.logging.warning(f'count_tracked_open_issues: GraphQL failure for user {github_user_node_id}')
return None

# data.node can be explicitly null (deleted account, wrong id) — guard it.
node = ((result.get('data') or {}).get('node')) or {}
issues_block = node.get('issues') or {}
for issue_node in issues_block.get('nodes') or []:
repo_name = ((issue_node or {}).get('repository') or {}).get('nameWithOwner')
if repo_name and repo_name.lower() in tracked_lower:
count += 1

page_info = issues_block.get('pageInfo') or {}
if not page_info.get('hasNextPage'):
return count
cursor = page_info.get('endCursor')

bt.logging.warning(
f'count_tracked_open_issues: hit {max_pages}-page cap for user {github_user_node_id}, returning partial count'
)
return count


def load_miners_prs(
miner_eval: MinerEvaluation, master_repositories: Dict[str, RepositoryConfig], max_prs: int = 1000
) -> None:
Expand Down Expand Up @@ -998,10 +1060,6 @@ def load_miners_prs(
bt.logging.warning('User not found or no pull requests')
break

# Extract open issue count from first page (User-level field, not paginated)
if cursor is None:
miner_eval.total_open_issues = user_data.get('issues', {}).get('totalCount', 0)

pr_data: Dict = user_data.get('pullRequests', {})
prs: List = pr_data.get('nodes', [])
page_info: Dict = pr_data.get('pageInfo', {})
Expand Down Expand Up @@ -1058,9 +1116,18 @@ def load_miners_prs(
except Exception as e:
bt.logging.error(f'Unexpected error fetching PRs via GraphQL: {e}')

# Count open issues scoped to tracked repos (anti-spam gate).
# Must be scoped — a global count penalises miners for unrelated personal projects.
scoped_count = count_tracked_open_issues(
miner_eval.github_pat,
global_user_id,
set(master_repositories.keys()),
)
miner_eval.total_open_issues = scoped_count if scoped_count is not None else 0

bt.logging.info(
f'Fetched {len(miner_eval.merged_pull_requests)} merged PRs, {len(miner_eval.open_pull_requests)} open PRs, '
f'{len(miner_eval.closed_pull_requests)} closed'
f'{len(miner_eval.closed_pull_requests)} closed, {miner_eval.total_open_issues} tracked-repo open issues'
)


Expand Down
111 changes: 111 additions & 0 deletions tests/utils/test_count_tracked_open_issues.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"""Scoped open issue count — filters User.issues to tracked repos only.

Fixes the bug where the spam multiplier gated on the global User.issues.totalCount
(including personal projects and non-tracked upstreams), silently zeroing
miner scores.
"""

from unittest.mock import patch

from gittensor.utils.github_api_tools import count_tracked_open_issues

TRACKED = {'owner/repo-a', 'owner/repo-b'}


def _page(nodes, has_next=False, cursor=None):
return {
'data': {
'node': {
'issues': {
'pageInfo': {'hasNextPage': has_next, 'endCursor': cursor},
'nodes': nodes,
}
}
}
}


def _issue(repo):
return {'repository': {'nameWithOwner': repo}}


def test_counts_only_tracked_repos():
response = _page(
[
_issue('owner/repo-a'),
_issue('owner/repo-a'),
_issue('someone/personal-sandbox'), # not tracked → must be excluded
_issue('owner/repo-b'),
_issue('other/upstream'), # not tracked → must be excluded
]
)
with patch('gittensor.utils.github_api_tools.execute_graphql_query', return_value=response):
assert count_tracked_open_issues('tok', 'node-id', TRACKED) == 3


def test_empty_tracked_set_returns_zero():
# Short-circuit without hitting the API.
with patch('gittensor.utils.github_api_tools.execute_graphql_query') as mock:
assert count_tracked_open_issues('tok', 'node-id', set()) == 0
mock.assert_not_called()


def test_missing_token_returns_zero():
with patch('gittensor.utils.github_api_tools.execute_graphql_query') as mock:
assert count_tracked_open_issues('', 'node-id', TRACKED) == 0
mock.assert_not_called()


def test_graphql_failure_returns_none():
"""None = signal to caller; caller defaults to 0 (no penalty) on failure.
Safer direction — a broken fetch must not silently demote a miner."""
with patch('gittensor.utils.github_api_tools.execute_graphql_query', return_value=None):
assert count_tracked_open_issues('tok', 'node-id', TRACKED) is None


def test_graphql_error_block_returns_none():
with patch(
'gittensor.utils.github_api_tools.execute_graphql_query',
return_value={'errors': [{'message': 'boom'}]},
):
assert count_tracked_open_issues('tok', 'node-id', TRACKED) is None


def test_pagination_accumulates_across_pages():
pages = [
_page([_issue('owner/repo-a'), _issue('other/upstream')], has_next=True, cursor='c1'),
_page([_issue('owner/repo-b'), _issue('owner/repo-a')], has_next=False),
]
with patch('gittensor.utils.github_api_tools.execute_graphql_query', side_effect=pages):
assert count_tracked_open_issues('tok', 'node-id', TRACKED) == 3


def test_hits_max_pages_cap_returns_partial():
"""Pathological account with huge open-issue history — return partial rather than crash."""
cap = 2
pages = [_page([_issue('owner/repo-a')], has_next=True, cursor=f'c{i}') for i in range(cap)]
with patch('gittensor.utils.github_api_tools.execute_graphql_query', side_effect=pages):
result = count_tracked_open_issues('tok', 'node-id', TRACKED, max_pages=cap)
assert result == cap # one hit per page


def test_case_insensitive_repo_match():
"""master_repositories is lowercased by load_master_repo_weights, but
GitHub returns nameWithOwner in original case. Both sides must normalize."""
response = _page(
[
_issue('Owner/Repo-A'), # mixed case from GitHub
_issue('OWNER/REPO-B'), # all caps
_issue('Other/Upstream'), # not tracked
]
)
with patch('gittensor.utils.github_api_tools.execute_graphql_query', return_value=response):
assert count_tracked_open_issues('tok', 'node-id', TRACKED) == 2


def test_null_node_does_not_crash():
"""data.node can be explicitly null (deleted account, wrong id) — must not
raise AttributeError on None.get('issues')."""
response = {'data': {'node': None}}
with patch('gittensor.utils.github_api_tools.execute_graphql_query', return_value=response):
assert count_tracked_open_issues('tok', 'node-id', TRACKED) == 0