diff --git a/gittensor/utils/mirror/models.py b/gittensor/utils/mirror/models.py index 1894dbf2..47bb4ad4 100644 --- a/gittensor/utils/mirror/models.py +++ b/gittensor/utils/mirror/models.py @@ -73,7 +73,15 @@ def from_dict(cls, data: dict) -> 'MirrorReviewSummary': @dataclass class MirrorLinkedIssue: - """Issue that a PR closes, as nested inside a ``MirrorPullRequest``.""" + """Issue that a PR closes, as nested inside a ``MirrorPullRequest``. + + ``repository_full_name`` is the home repo of the issue itself — distinct + from the home repo of the PR that closes it. GitHub's ``Closes + owner/other-repo#N`` syntax lets a PR reference issues in different repos; + without this field the validator can't reject cross-repo linked issues + from the issue-bonus multiplier (see ``_is_valid_linked_issue``). + Defaults to ``None`` for mirror snapshots predating the schema addition. + """ number: int title: str @@ -87,6 +95,7 @@ class MirrorLinkedIssue: is_transferred: bool solved_by_pr: Optional[int] labels: List[MirrorLabel] = field(default_factory=list) + repository_full_name: Optional[str] = None @classmethod def from_dict(cls, data: dict) -> 'MirrorLinkedIssue': @@ -94,6 +103,9 @@ def from_dict(cls, data: dict) -> 'MirrorLinkedIssue': # 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') + # Lowercased to match MirrorPullRequest.repo_full_name normalization; + # the cross-repo guard compares the two case-insensitively. + repo_full_name = data.get('repository_full_name') return cls( number=data['number'], title=data.get('title', ''), @@ -107,6 +119,7 @@ def from_dict(cls, data: dict) -> 'MirrorLinkedIssue': is_transferred=bool(data.get('is_transferred', False)), solved_by_pr=data.get('solved_by_pr'), labels=[MirrorLabel.from_dict(label) for label in data.get('labels') or []], + repository_full_name=repo_full_name.lower() if repo_full_name else None, ) diff --git a/gittensor/validator/oss_contributions/mirror/scoring.py b/gittensor/validator/oss_contributions/mirror/scoring.py index bda651e0..303082a8 100644 --- a/gittensor/validator/oss_contributions/mirror/scoring.py +++ b/gittensor/validator/oss_contributions/mirror/scoring.py @@ -432,6 +432,12 @@ def _is_valid_linked_issue(li: MirrorLinkedIssue, pr: MirrorPullRequest) -> bool - Reject transferred issues. - Missing author / self-issue (uses github_id for immutability). - Issue created after the PR. + - Cross-repo references: when the mirror surfaces the issue's home repo + and it differs from the PR's repo, reject — a ``Closes other/repo#N`` + keyword should not pay the issue-bonus multiplier in this repo. Fails + open when ``repository_full_name`` is ``None`` (older mirror snapshots + without the field); the guard arms automatically once das-github-mirror + populates the field. - Any CLOSED issue must have state_reason=COMPLETED — NOT_PLANNED / reopened closures never grant a multiplier. Applies regardless of PR state, so the gate covers OPEN-PR collateral as well. @@ -456,6 +462,17 @@ def _is_valid_linked_issue(li: MirrorLinkedIssue, pr: MirrorPullRequest) -> bool bt.logging.warning(f'Skipping linked issue #{li.number} - created after PR') return False + # Mirror payload may omit repository identity on older snapshots; only + # gate when the field is populated. Both sides are normalized to lowercase + # at parse time (see ``MirrorLinkedIssue.from_dict`` and + # ``MirrorPullRequest.from_dict``), so a direct ``!=`` is case-correct. + if li.repository_full_name is not None and li.repository_full_name != pr.repo_full_name: + bt.logging.warning( + f'Skipping linked issue #{li.number} - cross-repo reference ' + f'(issue in {li.repository_full_name}, PR in {pr.repo_full_name})' + ) + return False + # state_reason check applies regardless of PR state — OPEN-PR collateral # also requires that any CLOSED linked issue closed as COMPLETED. if li.state == 'CLOSED' and li.state_reason != 'COMPLETED': diff --git a/tests/validator/oss_contributions/mirror/test_scoring.py b/tests/validator/oss_contributions/mirror/test_scoring.py index c14b792e..0e2642bb 100644 --- a/tests/validator/oss_contributions/mirror/test_scoring.py +++ b/tests/validator/oss_contributions/mirror/test_scoring.py @@ -706,8 +706,9 @@ def _linked_issue( closed_at: str | None = '2026-04-18T10:00:00Z', author_association: str | None = 'CONTRIBUTOR', number: int = 50, + repository_full_name: str | None = None, ): - return { + payload = { 'number': number, 'title': 't', 'state': state, @@ -721,6 +722,9 @@ def _linked_issue( 'solved_by_pr': 100, 'labels': [], } + if repository_full_name is not None: + payload['repository_full_name'] = repository_full_name + return payload class TestIssueMultiplier: @@ -823,6 +827,62 @@ def test_open_issue_on_open_pr_still_valid(self): assert _is_valid_linked_issue(li, scored.pr) is True +class TestLinkedIssueCrossRepo: + """Cross-repo `Closes owner/other-repo#N` references must not pay the issue + multiplier on a PR in a different repo (parity with #1019 / PR #1038 on the + legacy path, ported here after the mirror-only cutover #1202). + + The mirror payload may omit ``repository_full_name`` on older snapshots; in + that case the guard fails open so existing data behaves as before. Once + das-github-mirror populates the field, the guard arms automatically. + """ + + def test_cross_repo_linked_issue_rejected(self): + """PR in `entrius/gittensor-ui` linked to issue in `outsider/throwaway` + — the guard must reject.""" + scored = ScoredPR(pr=_pr()) # repo_full_name='entrius/gittensor-ui' + li = MirrorLinkedIssue.from_dict(_linked_issue(repository_full_name='outsider/throwaway')) + assert _is_valid_linked_issue(li, scored.pr) is False + + def test_same_repo_linked_issue_passes(self): + """Same repo populated explicitly — guard does not fire.""" + scored = ScoredPR(pr=_pr()) + li = MirrorLinkedIssue.from_dict(_linked_issue(repository_full_name='entrius/gittensor-ui')) + assert _is_valid_linked_issue(li, scored.pr) is True + + def test_repo_identity_missing_falls_open(self): + """Pre-schema mirror rows have no ``repository_full_name``; guard must + not fire (backwards-compat with current production mirror).""" + scored = ScoredPR(pr=_pr()) + li = MirrorLinkedIssue.from_dict(_linked_issue()) # field absent + assert li.repository_full_name is None + assert _is_valid_linked_issue(li, scored.pr) is True + + def test_cross_repo_case_insensitive(self): + """Mirror normalizes repo names to lowercase at parse time; the + guard's direct ``!=`` comparison must remain correct against + mixed-case payloads.""" + scored = ScoredPR(pr=_pr()) + li = MirrorLinkedIssue.from_dict(_linked_issue(repository_full_name='OUTSIDER/Throwaway')) + # Mirror lowercased the field at parse time + assert li.repository_full_name == 'outsider/throwaway' + assert _is_valid_linked_issue(li, scored.pr) is False + + def test_cross_repo_multiplier_stays_neutral(self): + """End-to-end: a cross-repo linked issue should not produce a + STANDARD/MAINTAINER multiplier from ``_calculate_issue_multiplier``.""" + scored = ScoredPR(pr=_pr(linked_issues=[_linked_issue(repository_full_name='outsider/throwaway')])) + assert _calculate_issue_multiplier(scored) == 1.0 + + def test_same_repo_multiplier_still_applies(self): + """End-to-end regression guard: a known same-repo linked issue still + earns the standard multiplier.""" + from gittensor.constants import STANDARD_ISSUE_MULTIPLIER + + scored = ScoredPR(pr=_pr(linked_issues=[_linked_issue(repository_full_name='entrius/gittensor-ui')])) + assert _calculate_issue_multiplier(scored) == STANDARD_ISSUE_MULTIPLIER + + class TestIssueMultiplierPreference: def test_prefer_maintainer_authored_when_multiple_valid(self): """Legacy parity (PR #673): the issue multiplier should pick a