diff --git a/packages/darnit/src/darnit/context/sieve.py b/packages/darnit/src/darnit/context/sieve.py index 632d513b..23b71b36 100644 --- a/packages/darnit/src/darnit/context/sieve.py +++ b/packages/darnit/src/darnit/context/sieve.py @@ -518,25 +518,23 @@ def _detect_governance_deterministic(self, local_path: str) -> list[ContextSigna # ========================================================================= def _parse_maintainers_file(self, content: str) -> list[str]: - """Parse maintainers from MAINTAINERS.md content. + """Parse maintainer handles from MAINTAINERS.md content. - Looks for: - - @username mentions - - GitHub usernames in lists - - Email addresses with names + Returns only ``@username`` handles. Email addresses are stripped + before matching so a domain (``realcorp`` in ``alice@realcorp.io``) + is never read as a handle, and display names are left out: a bare + name is not a GitHub handle, and GitHub rejects it as a code owner. """ - maintainers = [] - - # Pattern for @username - at_mentions = re.findall(r"@([a-zA-Z0-9][-a-zA-Z0-9]*)", content) - maintainers.extend([f"@{m}" for m in at_mentions]) - - # Pattern for "Name (email)" or "Name " - name_email = re.findall( - r"[-*]\s*([A-Z][a-zA-Z]+(?:\s+[A-Z][a-zA-Z]+)*)\s*[(<]", - content, + # Strip email addresses first: the @ in alice@example.com is never a + # handle, and without this its domain would match the handle pattern. + no_emails = re.sub( + r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", "", content ) - maintainers.extend(name_email) + + # Pattern for @username. The lookbehind keeps the @ of any surviving + # address-like text (e.g. user@localhost) from matching as a handle. + at_mentions = re.findall(r"(?= 0.8 + @pytest.mark.unit + def test_detect_maintainers_from_file_ignores_email_domain(self, temp_repo): + """An email domain must not become a maintainer handle (issue #464).""" + (Path(temp_repo) / "MAINTAINERS.md").write_text( + "# Maintainers\n\n- Alice Example @alice\n" + ) + + sieve = ContextSieve() + result = sieve.detect("maintainers", temp_repo, "owner", "repo") + + assert result.key == "maintainers" + assert "@alice" in result.value + assert not any("realcorp" in str(v).lower() for v in result.value) + + @pytest.mark.unit + def test_parse_maintainers_file_email_domain_not_a_handle(self): + """The issue's exact example: only the real handle is returned.""" + sieve = ContextSieve() + assert ( + sieve._parse_maintainers_file("- Alice Example @alice\n") + == ["@alice"] + ) + + @pytest.mark.unit + def test_parse_maintainers_file_name_and_email_without_handle(self): + """A display name plus email, with no @handle, yields no maintainers.""" + sieve = ContextSieve() + assert sieve._parse_maintainers_file("- Alice Example \n") == [] + + @pytest.mark.unit + def test_parse_maintainers_file_plain_email_yields_nothing(self): + """A bare email address contributes no handle.""" + sieve = ContextSieve() + assert sieve._parse_maintainers_file("Contact: bob@example.com\n") == [] + + @pytest.mark.unit + def test_parse_maintainers_file_keeps_plain_handles(self): + """Ordinary @mentions still parse as before.""" + sieve = ContextSieve() + assert sieve._parse_maintainers_file( + "- @alice - Lead maintainer\n- @bob - Core contributor\n" + ) == ["@alice", "@bob"] + + @pytest.mark.unit + def test_parse_maintainers_file_handle_glued_to_email(self): + """A handle written directly after an email still parses.""" + sieve = ContextSieve() + assert ( + sieve._parse_maintainers_file("- Alice Example @alice\n") + == ["@alice"] + ) + @pytest.mark.unit def test_detect_maintainers_from_codeowners(self, temp_repo): """Detects maintainers from CODEOWNERS file."""