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
2 changes: 1 addition & 1 deletion gittensor/classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ class MinerEvaluation:
total_solved_issues: int = 0
total_valid_solved_issues: int = 0 # solved issues where solving PR has token_score >= 5
total_closed_issues: int = 0
total_open_issues: int = 0 # mirror-tracked open issues in lookback window (set by issue_discovery.scan)
total_open_issues: int = 0 # current mirror-tracked open issues (set by issue_discovery.scan)

@property
def total_prs(self) -> int:
Expand Down
23 changes: 16 additions & 7 deletions gittensor/validator/issue_discovery/scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,17 +164,23 @@ async def run_issue_discovery(
fetch_errors += 1
continue

try:
current_response = await asyncio.to_thread(client.get_miner_issues, evaluation.github_id)
except MirrorRequestError as e:
bt.logging.warning(f'├─ UID {uid}: open-issue count fetch failed ({e}) — skipped this miner')
_restore_issue_discovery_from_cache(evaluation, evaluation_cache)
fetch_errors += 1
continue

open_issue_count = _count_open_issues(current_response.issues, enabled_names)
filtered = [i for i in response.issues if i.repo_full_name in enabled_names]
if not filtered:
_clear_issue_discovery_fields(evaluation)
evaluation.total_open_issues = open_issue_count
cacheable_uids.add(uid)
no_issues += 1
continue

# Count this miner's currently-open issues across registered repos
# (within the lookback window). Used as the spam-multiplier signal and
# also written to evaluation.total_open_issues for the DB row.
open_issue_count = sum(1 for i in filtered if i.state == 'OPEN')
pending.append((evaluation, filtered, open_issue_count))

canonical_pr_owners = _build_canonical_pr_owners(pending)
Expand Down Expand Up @@ -287,6 +293,10 @@ def _build_canonical_pr_owners(
return canonical


def _count_open_issues(issues: List[MirrorIssue], enabled_names: Set[str]) -> int:
return sum(1 for issue in issues if issue.repo_full_name in enabled_names and issue.state == 'OPEN')


def _build_solving_pr_cache(
miner_evaluations: Dict[int, MinerEvaluation],
) -> Dict[Tuple[str, int], CachedSolvingPR]:
Expand Down Expand Up @@ -325,9 +335,8 @@ async def _score_miner_issues(
) -> bool:
"""Classify + score one miner's mirror issues, populate MinerEvaluation fields.

``open_issue_count`` is the miner's currently-OPEN issue count across
registered repos within the lookback window — the source-of-truth for the
open-issue spam multiplier.
``open_issue_count`` is the miner's current OPEN issue count across
registered repos, independent of the issue-scoring lookback window.

``canonical_pr_owners`` enforces the cross-miner one-issue-per-PR rule:
only the marker-matching issue scores, siblings count for credibility.
Expand Down
44 changes: 42 additions & 2 deletions tests/validator/issue_discovery/test_scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -928,9 +928,49 @@ def test_unavailable_scoring_data_is_not_cached_across_sibling_lookups(self):

class TestOpenIssueSpamSourceIsMirror:
"""The open-issue spam multiplier sources its count from mirror's response,
and mirror_scan also writes that count to evaluation.total_open_issues so
and scan also writes that count to evaluation.total_open_issues so
the DB row reflects mirror-scoped state."""

def test_old_open_issues_outside_scoring_window_still_trip_spam(self):
"""Scoring stays lookback-bounded, but open-issue load is current."""
solved_issues = [_issue_dict(issue_number=300 + i, author_github_id=f'discoverer{i}') for i in range(8)]
old_open_issues = [
_issue_dict(
issue_number=200 + i,
state='OPEN',
state_reason=None,
solved_by_pr=None,
created_at='2026-01-01T00:00:00Z',
)
for i in range(6)
]
client = Mock()
client.get_miner_issues.side_effect = [
_response(solved_issues), # lookback-bounded scoring response
_response(old_open_issues), # current/open-count response
]

eval_ = _eval()
eval_.merged_prs = [_scored_mirror_pr('entrius/gittensor-ui', 100, token_score=100.0)]

_run(
run_issue_discovery(
{1: eval_},
_mirror_repos('entrius/gittensor-ui'),
_EMPTY_LANGS,
_EMPTY_TOKEN_CONFIG,
client=client,
)
)

assert eval_.total_open_issues == 6
assert eval_.issue_discovery_score == 0
assert client.get_miner_issues.call_count == 2
scoring_since = client.get_miner_issues.call_args_list[0].kwargs['since']
open_count_call = client.get_miner_issues.call_args_list[1]
assert scoring_since is not None
assert open_count_call.kwargs.get('since') is None

def test_all_mirror_miner_with_many_open_issues_trips_spam(self):
"""6 open issues in mirror response trips the spam multiplier."""
open_issues = [
Expand Down Expand Up @@ -963,7 +1003,7 @@ def test_all_mirror_miner_with_many_open_issues_trips_spam(self):

# 6 open issues > threshold (5) → spam_mult=0 → all scored issues earn 0
assert eval_.issue_discovery_score == 0
# mirror_scan now records the mirror-scoped open count
# scan records the mirror-scoped open count
assert eval_.total_open_issues == 6

def test_all_mirror_miner_below_threshold_passes_spam(self):
Expand Down
Loading