Skip to content

[Bug] MirrorPullRequest and MirrorSolvingPR coerce null author_github_id to literal string 'None', breaking self-issue equality checks #1089

@ebios-star

Description

@ebios-star

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions