diff --git a/src/reposteward/github.py b/src/reposteward/github.py index b3bd572..40cc9e3 100644 --- a/src/reposteward/github.py +++ b/src/reposteward/github.py @@ -757,7 +757,11 @@ def open_pull_request_references( ) -> dict[int, tuple[CompetingWork, ...]]: references: dict[int, list[CompetingWork]] = {} seen_pulls: set[int] = set() - issue_reference = re.compile(r"(?[\w.-]+)/(?P[\w.-]+)#(?P\d+)(?!\d)" + ) + target_full_name = full_name.casefold() for page in range(1, 3): pulls, _ = self._request( "GET", @@ -785,8 +789,20 @@ def open_pull_request_references( url=str(pull.get("html_url") or ""), detail=f"#{pull_number}: {pull.get('title', '')}", ) - for match in issue_reference.finditer(body): - number = int(match.group(1)) + # GitHub auto-links both bare "#N" and qualified "owner/name#N" + # references. A qualified reference only targets this repository + # when its qualifier equals full_name (case-insensitively); + # qualifiers pointing at other repositories, and URL fragments + # such as "#issuecomment-1", must never count as competing work. + referenced: set[int] = set() + for match in qualified_issue_reference.finditer(body): + qualifier = f"{match.group('owner')}/{match.group('name')}" + if qualifier.casefold() != target_full_name: + continue + referenced.add(int(match.group("num"))) + for match in bare_issue_reference.finditer(body): + referenced.add(int(match.group(1))) + for number in sorted(referenced): references.setdefault(number, []).append(conflict) if len(pulls) < 100: break diff --git a/tests/test_github.py b/tests/test_github.py index ef5afa7..0b6cbcc 100644 --- a/tests/test_github.py +++ b/tests/test_github.py @@ -254,6 +254,74 @@ def request(method: str, path: str, **kwargs: Any) -> tuple[Any, Any]: {"claim_comment", "open_pull_request"}, ) + @staticmethod + def _pull_request_client(body: str) -> tuple[GitHubClient, Any]: + client = GitHubClient(GitHubConfig(), token="test-token") + + def request(method: str, path: str, **kwargs: Any) -> tuple[Any, Any]: + if path.endswith("/comments"): + return [], None + return [ + { + "number": 5045, + "title": "fix(frontend): keep renamed thread titles in sync", + "body": body, + "html_url": "https://example.test/pr/5045", + "user": {"login": "jiaqiang000"}, + "head": {"repo": {"owner": {"login": "jiaqiang000"}}}, + } + ], None + + return client, request + + def test_qualified_cross_repository_reference_is_blocker(self) -> None: + # Regression for the deer-flow #5043/#5045 miss: the open PR referenced + # the issue as "Fixes bytedance/deer-flow#5043" and the gate saw nothing. + client, request = self._pull_request_client("Fixes bytedance/deer-flow#5043") + with patch.object(client, "_request", side_effect=request): + conflicts = client.competing_work( + "bytedance/deer-flow", 5043, own_login="betterkite" + ) + self.assertEqual([value.kind for value in conflicts], ["open_pull_request"]) + self.assertEqual(conflicts[0].actor, "jiaqiang000") + + def test_qualified_reference_to_other_repository_is_ignored(self) -> None: + client, request = self._pull_request_client("Fixes other/repo#5043") + with patch.object(client, "_request", side_effect=request): + conflicts = client.competing_work( + "bytedance/deer-flow", 5043, own_login="betterkite" + ) + self.assertEqual(conflicts, ()) + + def test_qualified_reference_matches_case_insensitively(self) -> None: + client, request = self._pull_request_client("Fixes Bytedance/Deer-Flow#5043") + with patch.object(client, "_request", side_effect=request): + conflicts = client.competing_work( + "bytedance/deer-flow", 5043, own_login="betterkite" + ) + self.assertEqual([value.kind for value in conflicts], ["open_pull_request"]) + + def test_bare_and_qualified_references_deduplicate(self) -> None: + client, request = self._pull_request_client( + "Fixes #5043 and bytedance/deer-flow#5043" + ) + with patch.object(client, "_request", side_effect=request): + references = client.open_pull_request_references( + "bytedance/deer-flow", own_login="betterkite" + ) + self.assertEqual(list(references), [5043]) + self.assertEqual(len(references[5043]), 1) + + def test_url_fragments_are_not_issue_references(self) -> None: + client, request = self._pull_request_client( + "Context: https://github.com/bytedance/deer-flow/pull/5045#issuecomment-1" + ) + with patch.object(client, "_request", side_effect=request): + conflicts = client.competing_work( + "bytedance/deer-flow", 5043, own_login="betterkite" + ) + self.assertEqual(conflicts, ()) + class GitHubPullRequestPaginationTests(unittest.TestCase): def test_open_pull_requests_follows_every_rest_page(self) -> None: