Description
In gittensor/utils/mirror/models.py, the four mirror dataclasses parse author_github_id from upstream JSON differently. Two of them silently turn an upstream null into the literal string 'None', which then propagates into anti-gaming equality checks downstream.
The inconsistency
MirrorLinkedIssue.from_dict gets it right and even documents the intent in the inline comment:
# Mirror sometimes serializes github_id as int; coerce to match the
# str-typed field so downstream `==` comparisons with author_github_id
# from MirrorPullRequest don't silently mismatch on type.
author_github_id = data.get('author_github_id')
return cls(
...,
author_github_id=str(author_github_id) if author_github_id is not None else None,
...,
)
MirrorIssue.from_dict follows the same null-preserving pattern.
But MirrorPullRequest.from_dict and MirrorSolvingPR.from_dict don't:
# MirrorPullRequest.from_dict, line 165:
author_github_id=str(data['author_github_id']),
# MirrorSolvingPR.from_dict, line 216:
author_github_id=str(data['author_github_id']),
str(None) in Python evaluates to the four-character string 'None', not None:
>>> str(None)
'None'
>>> str(None) is None
False
So a mirror response where the PR's author has no resolvable GitHub identity (deleted account, ghost author, schema drift) gets parsed as author_github_id='None' instead of None.
The consequence
The field is consumed in two anti-gaming equality checks:
1. mirror/scoring.py:446, in _is_valid_linked_issue:
if li.author_github_id is None:
bt.logging.warning(f'Skipping linked issue #{li.number} - missing author_github_id')
return False
if li.author_github_id == pr.author_github_id:
bt.logging.warning(f'Skipping linked issue #{li.number} - same author as PR (self-issue)')
return False
li.author_github_id is None correctly catches the "linked-issue side null" case (because MirrorLinkedIssue preserves None). But pr.author_github_id was parsed by MirrorPullRequest.from_dict, so a null on that side becomes 'None'. The equality on the next line then compares e.g. '12345' == 'None' → False, and the self-issue gate is silently bypassed.
2. issue_discovery/mirror_scan.py:332, in _score_miner_mirror_issues:
if issue.author_github_id == solving_pr.author_github_id:
# same-account: discoverer == solver → credibility only, no score
Same pattern. issue.author_github_id is real None (from MirrorIssue); solving_pr.author_github_id could be 'None' (from MirrorSolvingPR). The 'real_id' == 'None' comparison evaluates False, the same-account discoverer/solver gate is bypassed, and the issue scores instead of dropping to credibility-only.
Where it matters
The downstream code already shows the schema treats null as a real possibility:
- The
if li.author_github_id is None: return False guard at mirror/scoring.py:442 explicitly handles a null on the linked-issue side.
MirrorIssue.author_github_id and MirrorLinkedIssue.author_github_id are typed Optional[str].
MirrorPullRequest.author_github_id and MirrorSolvingPR.author_github_id are typed str, but the underlying data can be null all the same.
The two halves of each comparison must agree on how null is represented. Right now they don't.
Steps to Reproduce
>>> from gittensor.utils.mirror.models import MirrorPullRequest, MirrorIssue, MirrorLinkedIssue
>>> from gittensor.utils.mirror.models import MirrorReviewSummary
>>> pr_payload = {
... 'repo_full_name': 'foo/bar', 'pr_number': 1, 'state': 'MERGED',
... 'author_github_id': None, # ← null upstream
... 'created_at': '2026-01-01T00:00:00Z',
... 'review_summary': {},
... }
>>> pr = MirrorPullRequest.from_dict(pr_payload)
>>> pr.author_github_id
'None' # ← string, not None
>>> pr.author_github_id is None
False
>>> # Compare against a linked issue parsed correctly:
>>> li = MirrorLinkedIssue.from_dict({
... 'number': 1, 'state': 'CLOSED', 'author_github_id': None,
... })
>>> li.author_github_id is None
True
>>> li.author_github_id == pr.author_github_id
False # silently False — gate bypassed
Expected Behavior
Both MirrorPullRequest.author_github_id and MirrorSolvingPR.author_github_id preserve null exactly the way MirrorIssue and MirrorLinkedIssue do, so equality checks across the four shapes always compare apples to apples.
Suggested Fix
Apply the same null-preserving pattern that MirrorLinkedIssue.from_dict already documents and uses, in both call sites — and tighten the field types to Optional[str] to match the other two.
# gittensor/utils/mirror/models.py — MirrorPullRequest
class MirrorPullRequest:
- author_github_id: str
+ author_github_id: Optional[str]
...
@classmethod
def from_dict(cls, data: dict) -> 'MirrorPullRequest':
+ author_github_id = data.get('author_github_id')
...
return cls(
...,
- author_github_id=str(data['author_github_id']),
+ author_github_id=str(author_github_id) if author_github_id is not None else None,
...,
)
# gittensor/utils/mirror/models.py — MirrorSolvingPR — analogous edit
Downstream consumers don't need updates: _is_valid_linked_issue already short-circuits on li.author_github_id is None, and the symmetric guard for pr.author_github_id is None is the natural follow-up alongside this fix (or can be left to the existing equality check, which would now correctly compare None == None for the truly-anonymous self-issue edge case).
Scope
gittensor/utils/mirror/models.py only — MirrorPullRequest.from_dict and MirrorSolvingPR.from_dict (~2 hunks each, ~4 lines total). No behaviour change for the common case where the mirror returns a real id; the fix only affects the null branch that today produces the literal string 'None'.
Environment
Description
In gittensor/utils/mirror/models.py, the four mirror dataclasses parse
author_github_idfrom upstream JSON differently. Two of them silently turn an upstreamnullinto the literal string'None', which then propagates into anti-gaming equality checks downstream.The inconsistency
MirrorLinkedIssue.from_dictgets it right and even documents the intent in the inline comment:MirrorIssue.from_dictfollows the same null-preserving pattern.But
MirrorPullRequest.from_dictandMirrorSolvingPR.from_dictdon't:str(None)in Python evaluates to the four-character string'None', notNone:So a mirror response where the PR's author has no resolvable GitHub identity (deleted account, ghost author, schema drift) gets parsed as
author_github_id='None'instead ofNone.The consequence
The field is consumed in two anti-gaming equality checks:
1.
mirror/scoring.py:446, in_is_valid_linked_issue:li.author_github_id is Nonecorrectly catches the "linked-issue side null" case (becauseMirrorLinkedIssuepreserves None). Butpr.author_github_idwas parsed byMirrorPullRequest.from_dict, so a null on that side becomes'None'. The equality on the next line then compares e.g.'12345' == 'None'→False, and the self-issue gate is silently bypassed.2.
issue_discovery/mirror_scan.py:332, in_score_miner_mirror_issues:Same pattern.
issue.author_github_idis realNone(fromMirrorIssue);solving_pr.author_github_idcould be'None'(fromMirrorSolvingPR). The'real_id' == 'None'comparison evaluates False, the same-account discoverer/solver gate is bypassed, and the issue scores instead of dropping to credibility-only.Where it matters
The downstream code already shows the schema treats null as a real possibility:
if li.author_github_id is None: return Falseguard at mirror/scoring.py:442 explicitly handles a null on the linked-issue side.MirrorIssue.author_github_idandMirrorLinkedIssue.author_github_idare typedOptional[str].MirrorPullRequest.author_github_idandMirrorSolvingPR.author_github_idare typedstr, but the underlying data can be null all the same.The two halves of each comparison must agree on how null is represented. Right now they don't.
Steps to Reproduce
Expected Behavior
Both
MirrorPullRequest.author_github_idandMirrorSolvingPR.author_github_idpreserve null exactly the wayMirrorIssueandMirrorLinkedIssuedo, so equality checks across the four shapes always compare apples to apples.Suggested Fix
Apply the same null-preserving pattern that
MirrorLinkedIssue.from_dictalready documents and uses, in both call sites — and tighten the field types toOptional[str]to match the other two.Downstream consumers don't need updates:
_is_valid_linked_issuealready short-circuits onli.author_github_id is None, and the symmetric guard forpr.author_github_id is Noneis the natural follow-up alongside this fix (or can be left to the existing equality check, which would now correctly compareNone == Nonefor the truly-anonymous self-issue edge case).Scope
gittensor/utils/mirror/models.pyonly —MirrorPullRequest.from_dictandMirrorSolvingPR.from_dict(~2 hunks each, ~4 lines total). No behaviour change for the common case where the mirror returns a real id; the fix only affects the null branch that today produces the literal string'None'.Environment
gittensor/utils/mirror/models.py:165and:216testbool()coercion of strings onis_transferred/edited_after_merge/scoring_data_stored/is_binary— different field, different parsing primitive).