From e1b46585591a2f6a275aeb1501b9624817bea7b5 Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 17 Jun 2025 13:49:04 -0500 Subject: [PATCH 01/28] Add mention decorator for GitHub command handling --- src/django_github_app/commands.py | 41 +++++++ src/django_github_app/routing.py | 77 ++++++++++++- tests/test_routing.py | 175 ++++++++++++++++++++++++++++++ 3 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 src/django_github_app/commands.py diff --git a/src/django_github_app/commands.py b/src/django_github_app/commands.py new file mode 100644 index 0000000..9951478 --- /dev/null +++ b/src/django_github_app/commands.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from enum import Enum +from typing import NamedTuple + + +class EventAction(NamedTuple): + event: str + action: str + + +class CommandScope(str, Enum): + COMMIT = "commit" + ISSUE = "issue" + PR = "pr" + + def get_events(self) -> list[EventAction]: + match self: + case CommandScope.ISSUE: + return [ + EventAction("issue_comment", "created"), + ] + case CommandScope.PR: + return [ + EventAction("issue_comment", "created"), + EventAction("pull_request_review_comment", "created"), + EventAction("pull_request_review", "submitted"), + ] + case CommandScope.COMMIT: + return [ + EventAction("commit_comment", "created"), + ] + + @classmethod + def all_events(cls) -> list[EventAction]: + return list( + dict.fromkeys( + event_action for scope in cls for event_action in scope.get_events() + ) + ) + diff --git a/src/django_github_app/routing.py b/src/django_github_app/routing.py index 8217b03..ff71bc8 100644 --- a/src/django_github_app/routing.py +++ b/src/django_github_app/routing.py @@ -1,15 +1,20 @@ from __future__ import annotations +from asyncio import iscoroutinefunction from collections.abc import Awaitable from collections.abc import Callable +from functools import wraps from typing import Any +from typing import Protocol from typing import TypeVar +from typing import cast from django.utils.functional import classproperty from gidgethub import sansio from gidgethub.routing import Router as GidgetHubRouter from ._typing import override +from .commands import CommandScope AsyncCallback = Callable[..., Awaitable[None]] SyncCallback = Callable[..., None] @@ -17,6 +22,25 @@ CB = TypeVar("CB", AsyncCallback, SyncCallback) +class MentionHandlerBase(Protocol): + _mention_command: str | None + _mention_scope: CommandScope | None + _mention_permission: str | None + + +class AsyncMentionHandler(MentionHandlerBase, Protocol): + async def __call__( + self, event: sansio.Event, *args: Any, **kwargs: Any + ) -> None: ... + + +class SyncMentionHandler(MentionHandlerBase, Protocol): + def __call__(self, event: sansio.Event, *args: Any, **kwargs: Any) -> None: ... + + +MentionHandler = AsyncMentionHandler | SyncMentionHandler + + class GitHubRouter(GidgetHubRouter): _routers: list[GidgetHubRouter] = [] @@ -24,13 +48,64 @@ def __init__(self, *args) -> None: super().__init__(*args) GitHubRouter._routers.append(self) + @override + def add( + self, func: AsyncCallback | SyncCallback, event_type: str, **data_detail: Any + ) -> None: + """Override to accept both async and sync callbacks.""" + super().add(cast(AsyncCallback, func), event_type, **data_detail) + @classproperty def routers(cls): return list(cls._routers) def event(self, event_type: str, **kwargs: Any) -> Callable[[CB], CB]: def decorator(func: CB) -> CB: - self.add(func, event_type, **kwargs) # type: ignore[arg-type] + self.add(func, event_type, **kwargs) + return func + + return decorator + + def mention(self, **kwargs: Any) -> Callable[[CB], CB]: + def decorator(func: CB) -> CB: + command = kwargs.pop("command", None) + scope = kwargs.pop("scope", None) + permission = kwargs.pop("permission", None) + + @wraps(func) + async def async_wrapper( + event: sansio.Event, *args: Any, **wrapper_kwargs: Any + ) -> None: + # TODO: Parse comment body for mentions + # TODO: If command specified, check if it matches + # TODO: Check permissions + # For now, just call through + await func(event, *args, **wrapper_kwargs) # type: ignore[func-returns-value] + + @wraps(func) + def sync_wrapper( + event: sansio.Event, *args: Any, **wrapper_kwargs: Any + ) -> None: + # TODO: Parse comment body for mentions + # TODO: If command specified, check if it matches + # TODO: Check permissions + # For now, just call through + func(event, *args, **wrapper_kwargs) + + wrapper: MentionHandler + if iscoroutinefunction(func): + wrapper = cast(AsyncMentionHandler, async_wrapper) + else: + wrapper = cast(SyncMentionHandler, sync_wrapper) + + wrapper._mention_command = command.lower() if command else None + wrapper._mention_scope = scope + wrapper._mention_permission = permission + + events = scope.get_events() if scope else CommandScope.all_events() + for event_action in events: + self.add(wrapper, event_action.event, action=event_action.action, **kwargs) + return func return decorator diff --git a/tests/test_routing.py b/tests/test_routing.py index 6646d0c..fa88c87 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -1,9 +1,13 @@ from __future__ import annotations +import asyncio + import pytest from django.http import HttpRequest from django.http import JsonResponse +from gidgethub import sansio +from django_github_app.commands import CommandScope from django_github_app.github import SyncGitHubAPI from django_github_app.routing import GitHubRouter from django_github_app.views import BaseWebhookView @@ -109,3 +113,174 @@ def test_router_memory_stress_test_legacy(self): assert len(views) == view_count assert not all(view.router is view1_router for view in views) + + +class TestMentionDecorator: + def test_basic_mention_no_command(self, test_router): + handler_called = False + handler_args = None + + @test_router.mention() + def handle_mention(event, *args, **kwargs): + nonlocal handler_called, handler_args + handler_called = True + handler_args = (event, args, kwargs) + + event = sansio.Event( + {"action": "created", "comment": {"body": "@bot hello"}}, + event="issue_comment", + delivery_id="123", + ) + test_router.dispatch(event, None) + + assert handler_called + assert handler_args[0] == event + + def test_mention_with_command(self, test_router): + handler_called = False + + @test_router.mention(command="help") + def help_command(event, *args, **kwargs): + nonlocal handler_called + handler_called = True + return "help response" + + event = sansio.Event( + {"action": "created", "comment": {"body": "@bot help"}}, + event="issue_comment", + delivery_id="123", + ) + test_router.dispatch(event, None) + + assert handler_called + + def test_mention_with_scope(self, test_router): + pr_handler_called = False + + @test_router.mention(command="deploy", scope=CommandScope.PR) + def deploy_command(event, *args, **kwargs): + nonlocal pr_handler_called + pr_handler_called = True + + pr_event = sansio.Event( + {"action": "created", "comment": {"body": "@bot deploy"}}, + event="pull_request_review_comment", + delivery_id="123", + ) + test_router.dispatch(pr_event, None) + + assert pr_handler_called + + issue_event = sansio.Event( + {"action": "created", "comment": {"body": "@bot deploy"}}, + event="commit_comment", # This is NOT a PR event + delivery_id="124", + ) + pr_handler_called = False # Reset + + test_router.dispatch(issue_event, None) + + assert not pr_handler_called + + def test_mention_with_permission(self, test_router): + handler_called = False + + @test_router.mention(command="delete", permission="admin") + def delete_command(event, *args, **kwargs): + nonlocal handler_called + handler_called = True + + event = sansio.Event( + {"action": "created", "comment": {"body": "@bot delete"}}, + event="issue_comment", + delivery_id="123", + ) + test_router.dispatch(event, None) + + assert handler_called + + def test_case_insensitive_command(self, test_router): + handler_called = False + + @test_router.mention(command="HELP") + def help_command(event, *args, **kwargs): + nonlocal handler_called + handler_called = True + + event = sansio.Event( + {"action": "created", "comment": {"body": "@bot help"}}, + event="issue_comment", + delivery_id="123", + ) + test_router.dispatch(event, None) + + assert handler_called + + def test_multiple_decorators_on_same_function(self, test_router): + call_count = 0 + + @test_router.mention(command="help") + @test_router.mention(command="h") + @test_router.mention(command="?") + def help_command(event, *args, **kwargs): + nonlocal call_count + call_count += 1 + return f"help called {call_count} times" + + event1 = sansio.Event( + {"action": "created", "comment": {"body": "@bot help"}}, + event="issue_comment", + delivery_id="123", + ) + test_router.dispatch(event1, None) + + assert call_count == 3 + + call_count = 0 + event2 = sansio.Event( + {"action": "created", "comment": {"body": "@bot h"}}, + event="issue_comment", + delivery_id="124", + ) + test_router.dispatch(event2, None) + + assert call_count == 3 + + # This behavior will change once we implement command parsing + + def test_async_mention_handler(self, test_router): + handler_called = False + + @test_router.mention(command="async-test") + async def async_handler(event, *args, **kwargs): + nonlocal handler_called + handler_called = True + return "async response" + + event = sansio.Event( + {"action": "created", "comment": {"body": "@bot async-test"}}, + event="issue_comment", + delivery_id="123", + ) + + asyncio.run(test_router.adispatch(event, None)) + + assert handler_called + + def test_sync_mention_handler(self, test_router): + handler_called = False + + @test_router.mention(command="sync-test") + def sync_handler(event, *args, **kwargs): + nonlocal handler_called + handler_called = True + return "sync response" + + event = sansio.Event( + {"action": "created", "comment": {"body": "@bot sync-test"}}, + event="issue_comment", + delivery_id="123", + ) + test_router.dispatch(event, None) + + assert handler_called From 7e482dc53e28dbdaee9641acaf7f220328cb4f37 Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 17 Jun 2025 14:35:58 -0500 Subject: [PATCH 02/28] Add mention parsing and command extraction logic --- src/django_github_app/commands.py | 50 ++++++++ src/django_github_app/routing.py | 21 +++- tests/test_commands.py | 196 ++++++++++++++++++++++++++++++ tests/test_routing.py | 23 +--- 4 files changed, 268 insertions(+), 22 deletions(-) create mode 100644 tests/test_commands.py diff --git a/src/django_github_app/commands.py b/src/django_github_app/commands.py index 9951478..b584c95 100644 --- a/src/django_github_app/commands.py +++ b/src/django_github_app/commands.py @@ -1,6 +1,8 @@ from __future__ import annotations +import re from enum import Enum +from typing import Any from typing import NamedTuple @@ -39,3 +41,51 @@ def all_events(cls) -> list[EventAction]: ) ) + +class MentionMatch(NamedTuple): + mention: str + command: str | None + + +CODE_BLOCK_PATTERN = re.compile(r"```[\s\S]*?```", re.MULTILINE) +INLINE_CODE_PATTERN = re.compile(r"`[^`]+`") +QUOTE_PATTERN = re.compile(r"^\s*>.*$", re.MULTILINE) + + +def parse_mentions(text: str, username: str) -> list[MentionMatch]: + if not text: + return [] + + text = CODE_BLOCK_PATTERN.sub(lambda m: " " * len(m.group(0)), text) + text = INLINE_CODE_PATTERN.sub(lambda m: " " * len(m.group(0)), text) + text = QUOTE_PATTERN.sub(lambda m: " " * len(m.group(0)), text) + + username_pattern = re.compile( + rf"(?:^|(?<=\s))(@{re.escape(username)})(?:\s+([\w\-?]+))?(?=\s|$|[^\w\-])", + re.MULTILINE | re.IGNORECASE, + ) + + mentions: list[MentionMatch] = [] + for match in username_pattern.finditer(text): + mention = match.group(1) # @username + command = match.group(2) # optional command + mentions.append( + MentionMatch(mention=mention, command=command.lower() if command else None) + ) + + return mentions + + +def check_event_for_mention( + event: dict[str, Any], command: str | None, username: str +) -> bool: + comment = event.get("comment", {}).get("body", "") + mentions = parse_mentions(comment, username) + + if not mentions: + return False + + if not command: + return True + + return any(mention.command == command.lower() for mention in mentions) diff --git a/src/django_github_app/routing.py b/src/django_github_app/routing.py index ff71bc8..b77ef99 100644 --- a/src/django_github_app/routing.py +++ b/src/django_github_app/routing.py @@ -15,6 +15,7 @@ from ._typing import override from .commands import CommandScope +from .commands import check_event_for_mention AsyncCallback = Callable[..., Awaitable[None]] SyncCallback = Callable[..., None] @@ -76,8 +77,12 @@ def decorator(func: CB) -> CB: async def async_wrapper( event: sansio.Event, *args: Any, **wrapper_kwargs: Any ) -> None: - # TODO: Parse comment body for mentions - # TODO: If command specified, check if it matches + # TODO: Get actual bot username from installation/app data + username = "bot" # Placeholder + + if not check_event_for_mention(event.data, command, username): + return + # TODO: Check permissions # For now, just call through await func(event, *args, **wrapper_kwargs) # type: ignore[func-returns-value] @@ -86,8 +91,12 @@ async def async_wrapper( def sync_wrapper( event: sansio.Event, *args: Any, **wrapper_kwargs: Any ) -> None: - # TODO: Parse comment body for mentions - # TODO: If command specified, check if it matches + # TODO: Get actual bot username from installation/app data + username = "bot" # Placeholder + + if not check_event_for_mention(event.data, command, username): + return + # TODO: Check permissions # For now, just call through func(event, *args, **wrapper_kwargs) @@ -104,7 +113,9 @@ def sync_wrapper( events = scope.get_events() if scope else CommandScope.all_events() for event_action in events: - self.add(wrapper, event_action.event, action=event_action.action, **kwargs) + self.add( + wrapper, event_action.event, action=event_action.action, **kwargs + ) return func diff --git a/tests/test_commands.py b/tests/test_commands.py new file mode 100644 index 0000000..1193eb1 --- /dev/null +++ b/tests/test_commands.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +from django_github_app.commands import check_event_for_mention +from django_github_app.commands import parse_mentions + + +class TestParseMentions: + def test_simple_mention_with_command(self): + text = "@mybot help" + mentions = parse_mentions(text, "mybot") + + assert len(mentions) == 1 + assert mentions[0].mention == "@mybot" + assert mentions[0].command == "help" + + def test_mention_without_command(self): + text = "@mybot" + mentions = parse_mentions(text, "mybot") + + assert len(mentions) == 1 + assert mentions[0].mention == "@mybot" + assert mentions[0].command is None + + def test_case_insensitive_matching(self): + text = "@MyBot help" + mentions = parse_mentions(text, "mybot") + + assert len(mentions) == 1 + assert mentions[0].mention == "@MyBot" + assert mentions[0].command == "help" + + def test_command_case_normalization(self): + text = "@mybot HELP" + mentions = parse_mentions(text, "mybot") + + assert len(mentions) == 1 + assert mentions[0].command == "help" + + def test_multiple_mentions(self): + text = "@mybot help and then @mybot deploy" + mentions = parse_mentions(text, "mybot") + + assert len(mentions) == 2 + assert mentions[0].command == "help" + assert mentions[1].command == "deploy" + + def test_ignore_other_mentions(self): + text = "@otheruser help @mybot deploy @someone else" + mentions = parse_mentions(text, "mybot") + + assert len(mentions) == 1 + assert mentions[0].command == "deploy" + + def test_mention_in_code_block(self): + text = """ + Here's some text + ``` + @mybot help + ``` + @mybot deploy + """ + mentions = parse_mentions(text, "mybot") + + assert len(mentions) == 1 + assert mentions[0].command == "deploy" + + def test_mention_in_inline_code(self): + text = "Use `@mybot help` for help, or just @mybot deploy" + mentions = parse_mentions(text, "mybot") + + assert len(mentions) == 1 + assert mentions[0].command == "deploy" + + def test_mention_in_quote(self): + text = """ + > @mybot help + @mybot deploy + """ + mentions = parse_mentions(text, "mybot") + + assert len(mentions) == 1 + assert mentions[0].command == "deploy" + + def test_empty_text(self): + mentions = parse_mentions("", "mybot") + + assert mentions == [] + + def test_none_text(self): + mentions = parse_mentions(None, "mybot") + + assert mentions == [] + + def test_mention_at_start_of_line(self): + text = "@mybot help" + mentions = parse_mentions(text, "mybot") + + assert len(mentions) == 1 + assert mentions[0].command == "help" + + def test_mention_in_middle_of_text(self): + text = "Hey @mybot help me" + mentions = parse_mentions(text, "mybot") + + assert len(mentions) == 1 + assert mentions[0].command == "help" + + def test_mention_with_punctuation_after(self): + text = "@mybot help!" + mentions = parse_mentions(text, "mybot") + + assert len(mentions) == 1 + assert mentions[0].command == "help" + + def test_hyphenated_username(self): + text = "@my-bot help" + mentions = parse_mentions(text, "my-bot") + + assert len(mentions) == 1 + assert mentions[0].mention == "@my-bot" + assert mentions[0].command == "help" + + def test_underscore_username(self): + text = "@my_bot help" + mentions = parse_mentions(text, "my_bot") + + assert len(mentions) == 1 + assert mentions[0].mention == "@my_bot" + assert mentions[0].command == "help" + + def test_no_space_after_mention(self): + text = "@mybot, please help" + mentions = parse_mentions(text, "mybot") + + assert len(mentions) == 1 + assert mentions[0].command is None + + def test_multiple_spaces_before_command(self): + text = "@mybot help" + mentions = parse_mentions(text, "mybot") + + assert len(mentions) == 1 + assert mentions[0].command == "help" + + def test_hyphenated_command(self): + text = "@mybot async-test" + mentions = parse_mentions(text, "mybot") + + assert len(mentions) == 1 + assert mentions[0].command == "async-test" + + def test_special_character_command(self): + text = "@mybot ?" + mentions = parse_mentions(text, "mybot") + + assert len(mentions) == 1 + assert mentions[0].command == "?" + + +class TestCheckMentionMatches: + def test_match_with_command(self): + event = {"comment": {"body": "@bot help"}} + + assert check_event_for_mention(event, "help", "bot") is True + assert check_event_for_mention(event, "deploy", "bot") is False + + def test_match_without_command(self): + event = {"comment": {"body": "@bot help"}} + + assert check_event_for_mention(event, None, "bot") is True + + event = {"comment": {"body": "no mention here"}} + + assert check_event_for_mention(event, None, "bot") is False + + def test_no_comment_body(self): + event = {} + + assert check_event_for_mention(event, "help", "bot") is False + + event = {"comment": {}} + + assert check_event_for_mention(event, "help", "bot") is False + + def test_case_insensitive_command_match(self): + event = {"comment": {"body": "@bot HELP"}} + + assert check_event_for_mention(event, "help", "bot") is True + assert check_event_for_mention(event, "HELP", "bot") is True + + def test_multiple_mentions(self): + event = {"comment": {"body": "@bot help @bot deploy"}} + + assert check_event_for_mention(event, "help", "bot") is True + assert check_event_for_mention(event, "deploy", "bot") is True + assert check_event_for_mention(event, "test", "bot") is False diff --git a/tests/test_routing.py b/tests/test_routing.py index fa88c87..d2746f7 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -216,7 +216,8 @@ def help_command(event, *args, **kwargs): assert handler_called - def test_multiple_decorators_on_same_function(self, test_router): + @pytest.mark.parametrize("comment", ["@bot help", "@bot h", "@bot ?"]) + def test_multiple_decorators_on_same_function(self, comment, test_router): call_count = 0 @test_router.mention(command="help") @@ -227,26 +228,14 @@ def help_command(event, *args, **kwargs): call_count += 1 return f"help called {call_count} times" - event1 = sansio.Event( - {"action": "created", "comment": {"body": "@bot help"}}, + event = sansio.Event( + {"action": "created", "comment": {"body": comment}}, event="issue_comment", delivery_id="123", ) - test_router.dispatch(event1, None) - - assert call_count == 3 - - call_count = 0 - event2 = sansio.Event( - {"action": "created", "comment": {"body": "@bot h"}}, - event="issue_comment", - delivery_id="124", - ) - test_router.dispatch(event2, None) - - assert call_count == 3 + test_router.dispatch(event, None) - # This behavior will change once we implement command parsing + assert call_count == 1 def test_async_mention_handler(self, test_router): handler_called = False From 09989c3d3e4f3b101e8d4b25159e233186ebf565 Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 17 Jun 2025 14:56:48 -0500 Subject: [PATCH 03/28] Add scope validation to mention decorator --- src/django_github_app/commands.py | 22 ++++ src/django_github_app/routing.py | 9 ++ tests/test_commands.py | 86 +++++++++++++++ tests/test_routing.py | 174 ++++++++++++++++++++++++++++++ 4 files changed, 291 insertions(+) diff --git a/src/django_github_app/commands.py b/src/django_github_app/commands.py index b584c95..e00a3b8 100644 --- a/src/django_github_app/commands.py +++ b/src/django_github_app/commands.py @@ -89,3 +89,25 @@ def check_event_for_mention( return True return any(mention.command == command.lower() for mention in mentions) + + +def check_event_scope( + event_type: str, event_data: dict[str, Any], scope: CommandScope | None +) -> bool: + if scope is None: + return True + + # For issue_comment events, we need to distinguish between issues and PRs + if event_type == "issue_comment": + issue = event_data.get("issue", {}) + is_pull_request = "pull_request" in issue and issue["pull_request"] is not None + + # If scope is ISSUE, we only want actual issues (not PRs) + if scope == CommandScope.ISSUE: + return not is_pull_request + # If scope is PR, we only want pull requests + elif scope == CommandScope.PR: + return is_pull_request + + scope_events = scope.get_events() + return any(event_action.event == event_type for event_action in scope_events) diff --git a/src/django_github_app/routing.py b/src/django_github_app/routing.py index b77ef99..e16ec9b 100644 --- a/src/django_github_app/routing.py +++ b/src/django_github_app/routing.py @@ -16,6 +16,7 @@ from ._typing import override from .commands import CommandScope from .commands import check_event_for_mention +from .commands import check_event_scope AsyncCallback = Callable[..., Awaitable[None]] SyncCallback = Callable[..., None] @@ -83,6 +84,10 @@ async def async_wrapper( if not check_event_for_mention(event.data, command, username): return + # Check if the event matches the specified scope + if not check_event_scope(event.event, event.data, scope): + return + # TODO: Check permissions # For now, just call through await func(event, *args, **wrapper_kwargs) # type: ignore[func-returns-value] @@ -97,6 +102,10 @@ def sync_wrapper( if not check_event_for_mention(event.data, command, username): return + # Check if the event matches the specified scope + if not check_event_scope(event.event, event.data, scope): + return + # TODO: Check permissions # For now, just call through func(event, *args, **wrapper_kwargs) diff --git a/tests/test_commands.py b/tests/test_commands.py index 1193eb1..7d9a4a2 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,6 +1,8 @@ from __future__ import annotations +from django_github_app.commands import CommandScope from django_github_app.commands import check_event_for_mention +from django_github_app.commands import check_event_scope from django_github_app.commands import parse_mentions @@ -194,3 +196,87 @@ def test_multiple_mentions(self): assert check_event_for_mention(event, "help", "bot") is True assert check_event_for_mention(event, "deploy", "bot") is True assert check_event_for_mention(event, "test", "bot") is False + + +class TestCheckEventScope: + def test_no_scope_allows_all_events(self): + # When no scope is specified, all events should pass + assert check_event_scope("issue_comment", {"issue": {}}, None) is True + assert check_event_scope("pull_request_review_comment", {}, None) is True + assert check_event_scope("commit_comment", {}, None) is True + + def test_issue_scope_on_issue_comment(self): + # Issue comment on an actual issue (no pull_request field) + issue_event = {"issue": {"title": "Bug report"}} + assert ( + check_event_scope("issue_comment", issue_event, CommandScope.ISSUE) is True + ) + + # Issue comment on a pull request (has pull_request field) + pr_event = {"issue": {"title": "PR title", "pull_request": {"url": "..."}}} + assert check_event_scope("issue_comment", pr_event, CommandScope.ISSUE) is False + + def test_pr_scope_on_issue_comment(self): + # Issue comment on an actual issue (no pull_request field) + issue_event = {"issue": {"title": "Bug report"}} + assert check_event_scope("issue_comment", issue_event, CommandScope.PR) is False + + # Issue comment on a pull request (has pull_request field) + pr_event = {"issue": {"title": "PR title", "pull_request": {"url": "..."}}} + assert check_event_scope("issue_comment", pr_event, CommandScope.PR) is True + + def test_pr_scope_allows_pr_specific_events(self): + # PR scope should allow pull_request_review_comment + assert ( + check_event_scope("pull_request_review_comment", {}, CommandScope.PR) + is True + ) + + # PR scope should allow pull_request_review + assert check_event_scope("pull_request_review", {}, CommandScope.PR) is True + + # PR scope should not allow commit_comment + assert check_event_scope("commit_comment", {}, CommandScope.PR) is False + + def test_commit_scope_allows_commit_comment_only(self): + # Commit scope should allow commit_comment + assert check_event_scope("commit_comment", {}, CommandScope.COMMIT) is True + + # Commit scope should not allow issue_comment + assert ( + check_event_scope("issue_comment", {"issue": {}}, CommandScope.COMMIT) + is False + ) + + # Commit scope should not allow PR events + assert ( + check_event_scope("pull_request_review_comment", {}, CommandScope.COMMIT) + is False + ) + + def test_issue_scope_disallows_non_issue_events(self): + # Issue scope should not allow pull_request_review_comment + assert ( + check_event_scope("pull_request_review_comment", {}, CommandScope.ISSUE) + is False + ) + + # Issue scope should not allow commit_comment + assert check_event_scope("commit_comment", {}, CommandScope.ISSUE) is False + + def test_pull_request_field_none_treated_as_issue(self): + # If pull_request field exists but is None, treat as issue + event_with_none_pr = {"issue": {"title": "Issue", "pull_request": None}} + assert ( + check_event_scope("issue_comment", event_with_none_pr, CommandScope.ISSUE) + is True + ) + assert ( + check_event_scope("issue_comment", event_with_none_pr, CommandScope.PR) + is False + ) + + def test_missing_issue_data(self): + # If issue data is missing entirely, default behavior + assert check_event_scope("issue_comment", {}, CommandScope.ISSUE) is True + assert check_event_scope("issue_comment", {}, CommandScope.PR) is False diff --git a/tests/test_routing.py b/tests/test_routing.py index d2746f7..00b62c3 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -273,3 +273,177 @@ def sync_handler(event, *args, **kwargs): test_router.dispatch(event, None) assert handler_called + + def test_scope_validation_issue_comment_on_issue(self, test_router): + """Test that ISSUE scope works for actual issues.""" + handler_called = False + + @test_router.mention(command="issue-only", scope=CommandScope.ISSUE) + def issue_handler(event, *args, **kwargs): + nonlocal handler_called + handler_called = True + + # Issue comment on an actual issue (no pull_request field) + event = sansio.Event( + { + "action": "created", + "issue": {"title": "Bug report", "number": 123}, + "comment": {"body": "@bot issue-only"}, + }, + event="issue_comment", + delivery_id="123", + ) + test_router.dispatch(event, None) + + assert handler_called + + def test_scope_validation_issue_comment_on_pr(self, test_router): + """Test that ISSUE scope rejects PR comments.""" + handler_called = False + + @test_router.mention(command="issue-only", scope=CommandScope.ISSUE) + def issue_handler(event, *args, **kwargs): + nonlocal handler_called + handler_called = True + + # Issue comment on a pull request (has pull_request field) + event = sansio.Event( + { + "action": "created", + "issue": { + "title": "PR title", + "number": 456, + "pull_request": {"url": "https://api.github.com/..."}, + }, + "comment": {"body": "@bot issue-only"}, + }, + event="issue_comment", + delivery_id="123", + ) + test_router.dispatch(event, None) + + assert not handler_called + + def test_scope_validation_pr_scope_on_pr(self, test_router): + """Test that PR scope works for pull requests.""" + handler_called = False + + @test_router.mention(command="pr-only", scope=CommandScope.PR) + def pr_handler(event, *args, **kwargs): + nonlocal handler_called + handler_called = True + + # Issue comment on a pull request + event = sansio.Event( + { + "action": "created", + "issue": { + "title": "PR title", + "number": 456, + "pull_request": {"url": "https://api.github.com/..."}, + }, + "comment": {"body": "@bot pr-only"}, + }, + event="issue_comment", + delivery_id="123", + ) + test_router.dispatch(event, None) + + assert handler_called + + def test_scope_validation_pr_scope_on_issue(self, test_router): + """Test that PR scope rejects issue comments.""" + handler_called = False + + @test_router.mention(command="pr-only", scope=CommandScope.PR) + def pr_handler(event, *args, **kwargs): + nonlocal handler_called + handler_called = True + + # Issue comment on an actual issue + event = sansio.Event( + { + "action": "created", + "issue": {"title": "Bug report", "number": 123}, + "comment": {"body": "@bot pr-only"}, + }, + event="issue_comment", + delivery_id="123", + ) + test_router.dispatch(event, None) + + assert not handler_called + + def test_scope_validation_commit_scope(self, test_router): + """Test that COMMIT scope works for commit comments.""" + handler_called = False + + @test_router.mention(command="commit-only", scope=CommandScope.COMMIT) + def commit_handler(event, *args, **kwargs): + nonlocal handler_called + handler_called = True + + # Commit comment event + event = sansio.Event( + { + "action": "created", + "comment": {"body": "@bot commit-only"}, + "commit": {"sha": "abc123"}, + }, + event="commit_comment", + delivery_id="123", + ) + test_router.dispatch(event, None) + + assert handler_called + + def test_scope_validation_no_scope(self, test_router): + """Test that no scope allows all comment types.""" + call_count = 0 + + @test_router.mention(command="all-contexts") + def all_handler(event, *args, **kwargs): + nonlocal call_count + call_count += 1 + + # Test on issue + event = sansio.Event( + { + "action": "created", + "issue": {"title": "Issue", "number": 1}, + "comment": {"body": "@bot all-contexts"}, + }, + event="issue_comment", + delivery_id="123", + ) + test_router.dispatch(event, None) + + # Test on PR + event = sansio.Event( + { + "action": "created", + "issue": { + "title": "PR", + "number": 2, + "pull_request": {"url": "..."}, + }, + "comment": {"body": "@bot all-contexts"}, + }, + event="issue_comment", + delivery_id="124", + ) + test_router.dispatch(event, None) + + # Test on commit + event = sansio.Event( + { + "action": "created", + "comment": {"body": "@bot all-contexts"}, + "commit": {"sha": "abc123"}, + }, + event="commit_comment", + delivery_id="125", + ) + test_router.dispatch(event, None) + + assert call_count == 3 From b58d1d4b67cff22f494e641583c8a7212594158b Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 17 Jun 2025 15:09:53 -0500 Subject: [PATCH 04/28] Refactor check_event functions to accept sansio.Event --- src/django_github_app/commands.py | 17 ++-- src/django_github_app/routing.py | 16 ++-- tests/test_commands.py | 124 ++++++++++++++++++------------ 3 files changed, 89 insertions(+), 68 deletions(-) diff --git a/src/django_github_app/commands.py b/src/django_github_app/commands.py index e00a3b8..4b16800 100644 --- a/src/django_github_app/commands.py +++ b/src/django_github_app/commands.py @@ -2,9 +2,10 @@ import re from enum import Enum -from typing import Any from typing import NamedTuple +from gidgethub import sansio + class EventAction(NamedTuple): event: str @@ -77,9 +78,9 @@ def parse_mentions(text: str, username: str) -> list[MentionMatch]: def check_event_for_mention( - event: dict[str, Any], command: str | None, username: str + event: sansio.Event, command: str | None, username: str ) -> bool: - comment = event.get("comment", {}).get("body", "") + comment = event.data.get("comment", {}).get("body", "") mentions = parse_mentions(comment, username) if not mentions: @@ -91,15 +92,13 @@ def check_event_for_mention( return any(mention.command == command.lower() for mention in mentions) -def check_event_scope( - event_type: str, event_data: dict[str, Any], scope: CommandScope | None -) -> bool: +def check_event_scope(event: sansio.Event, scope: CommandScope | None) -> bool: if scope is None: return True # For issue_comment events, we need to distinguish between issues and PRs - if event_type == "issue_comment": - issue = event_data.get("issue", {}) + if event.event == "issue_comment": + issue = event.data.get("issue", {}) is_pull_request = "pull_request" in issue and issue["pull_request"] is not None # If scope is ISSUE, we only want actual issues (not PRs) @@ -110,4 +109,4 @@ def check_event_scope( return is_pull_request scope_events = scope.get_events() - return any(event_action.event == event_type for event_action in scope_events) + return any(event_action.event == event.event for event_action in scope_events) diff --git a/src/django_github_app/routing.py b/src/django_github_app/routing.py index e16ec9b..a8a8824 100644 --- a/src/django_github_app/routing.py +++ b/src/django_github_app/routing.py @@ -81,15 +81,13 @@ async def async_wrapper( # TODO: Get actual bot username from installation/app data username = "bot" # Placeholder - if not check_event_for_mention(event.data, command, username): + if not check_event_for_mention(event, command, username): return - # Check if the event matches the specified scope - if not check_event_scope(event.event, event.data, scope): + if not check_event_scope(event, scope): return - # TODO: Check permissions - # For now, just call through + # TODO: Check permissions. For now, just call through. await func(event, *args, **wrapper_kwargs) # type: ignore[func-returns-value] @wraps(func) @@ -99,15 +97,13 @@ def sync_wrapper( # TODO: Get actual bot username from installation/app data username = "bot" # Placeholder - if not check_event_for_mention(event.data, command, username): + if not check_event_for_mention(event, command, username): return - # Check if the event matches the specified scope - if not check_event_scope(event.event, event.data, scope): + if not check_event_scope(event, scope): return - # TODO: Check permissions - # For now, just call through + # TODO: Check permissions. For now, just call through. func(event, *args, **wrapper_kwargs) wrapper: MentionHandler diff --git a/tests/test_commands.py b/tests/test_commands.py index 7d9a4a2..c4c2ae0 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,5 +1,7 @@ from __future__ import annotations +from gidgethub import sansio + from django_github_app.commands import CommandScope from django_github_app.commands import check_event_for_mention from django_github_app.commands import check_event_scope @@ -161,37 +163,51 @@ def test_special_character_command(self): class TestCheckMentionMatches: def test_match_with_command(self): - event = {"comment": {"body": "@bot help"}} + event = sansio.Event( + {"comment": {"body": "@bot help"}}, event="issue_comment", delivery_id="123" + ) assert check_event_for_mention(event, "help", "bot") is True assert check_event_for_mention(event, "deploy", "bot") is False def test_match_without_command(self): - event = {"comment": {"body": "@bot help"}} + event = sansio.Event( + {"comment": {"body": "@bot help"}}, event="issue_comment", delivery_id="123" + ) assert check_event_for_mention(event, None, "bot") is True - event = {"comment": {"body": "no mention here"}} + event = sansio.Event( + {"comment": {"body": "no mention here"}}, + event="issue_comment", + delivery_id="124", + ) assert check_event_for_mention(event, None, "bot") is False def test_no_comment_body(self): - event = {} + event = sansio.Event({}, event="issue_comment", delivery_id="123") assert check_event_for_mention(event, "help", "bot") is False - event = {"comment": {}} + event = sansio.Event({"comment": {}}, event="issue_comment", delivery_id="124") assert check_event_for_mention(event, "help", "bot") is False def test_case_insensitive_command_match(self): - event = {"comment": {"body": "@bot HELP"}} + event = sansio.Event( + {"comment": {"body": "@bot HELP"}}, event="issue_comment", delivery_id="123" + ) assert check_event_for_mention(event, "help", "bot") is True assert check_event_for_mention(event, "HELP", "bot") is True def test_multiple_mentions(self): - event = {"comment": {"body": "@bot help @bot deploy"}} + event = sansio.Event( + {"comment": {"body": "@bot help @bot deploy"}}, + event="issue_comment", + delivery_id="123", + ) assert check_event_for_mention(event, "help", "bot") is True assert check_event_for_mention(event, "deploy", "bot") is True @@ -201,82 +217,92 @@ def test_multiple_mentions(self): class TestCheckEventScope: def test_no_scope_allows_all_events(self): # When no scope is specified, all events should pass - assert check_event_scope("issue_comment", {"issue": {}}, None) is True - assert check_event_scope("pull_request_review_comment", {}, None) is True - assert check_event_scope("commit_comment", {}, None) is True + event1 = sansio.Event({"issue": {}}, event="issue_comment", delivery_id="1") + assert check_event_scope(event1, None) is True + + event2 = sansio.Event({}, event="pull_request_review_comment", delivery_id="2") + assert check_event_scope(event2, None) is True + + event3 = sansio.Event({}, event="commit_comment", delivery_id="3") + assert check_event_scope(event3, None) is True def test_issue_scope_on_issue_comment(self): # Issue comment on an actual issue (no pull_request field) - issue_event = {"issue": {"title": "Bug report"}} - assert ( - check_event_scope("issue_comment", issue_event, CommandScope.ISSUE) is True + issue_event = sansio.Event( + {"issue": {"title": "Bug report"}}, event="issue_comment", delivery_id="1" ) + assert check_event_scope(issue_event, CommandScope.ISSUE) is True # Issue comment on a pull request (has pull_request field) - pr_event = {"issue": {"title": "PR title", "pull_request": {"url": "..."}}} - assert check_event_scope("issue_comment", pr_event, CommandScope.ISSUE) is False + pr_event = sansio.Event( + {"issue": {"title": "PR title", "pull_request": {"url": "..."}}}, + event="issue_comment", + delivery_id="2", + ) + assert check_event_scope(pr_event, CommandScope.ISSUE) is False def test_pr_scope_on_issue_comment(self): # Issue comment on an actual issue (no pull_request field) - issue_event = {"issue": {"title": "Bug report"}} - assert check_event_scope("issue_comment", issue_event, CommandScope.PR) is False + issue_event = sansio.Event( + {"issue": {"title": "Bug report"}}, event="issue_comment", delivery_id="1" + ) + assert check_event_scope(issue_event, CommandScope.PR) is False # Issue comment on a pull request (has pull_request field) - pr_event = {"issue": {"title": "PR title", "pull_request": {"url": "..."}}} - assert check_event_scope("issue_comment", pr_event, CommandScope.PR) is True + pr_event = sansio.Event( + {"issue": {"title": "PR title", "pull_request": {"url": "..."}}}, + event="issue_comment", + delivery_id="2", + ) + assert check_event_scope(pr_event, CommandScope.PR) is True def test_pr_scope_allows_pr_specific_events(self): # PR scope should allow pull_request_review_comment - assert ( - check_event_scope("pull_request_review_comment", {}, CommandScope.PR) - is True - ) + event1 = sansio.Event({}, event="pull_request_review_comment", delivery_id="1") + assert check_event_scope(event1, CommandScope.PR) is True # PR scope should allow pull_request_review - assert check_event_scope("pull_request_review", {}, CommandScope.PR) is True + event2 = sansio.Event({}, event="pull_request_review", delivery_id="2") + assert check_event_scope(event2, CommandScope.PR) is True # PR scope should not allow commit_comment - assert check_event_scope("commit_comment", {}, CommandScope.PR) is False + event3 = sansio.Event({}, event="commit_comment", delivery_id="3") + assert check_event_scope(event3, CommandScope.PR) is False def test_commit_scope_allows_commit_comment_only(self): # Commit scope should allow commit_comment - assert check_event_scope("commit_comment", {}, CommandScope.COMMIT) is True + event1 = sansio.Event({}, event="commit_comment", delivery_id="1") + assert check_event_scope(event1, CommandScope.COMMIT) is True # Commit scope should not allow issue_comment - assert ( - check_event_scope("issue_comment", {"issue": {}}, CommandScope.COMMIT) - is False - ) + event2 = sansio.Event({"issue": {}}, event="issue_comment", delivery_id="2") + assert check_event_scope(event2, CommandScope.COMMIT) is False # Commit scope should not allow PR events - assert ( - check_event_scope("pull_request_review_comment", {}, CommandScope.COMMIT) - is False - ) + event3 = sansio.Event({}, event="pull_request_review_comment", delivery_id="3") + assert check_event_scope(event3, CommandScope.COMMIT) is False def test_issue_scope_disallows_non_issue_events(self): # Issue scope should not allow pull_request_review_comment - assert ( - check_event_scope("pull_request_review_comment", {}, CommandScope.ISSUE) - is False - ) + event1 = sansio.Event({}, event="pull_request_review_comment", delivery_id="1") + assert check_event_scope(event1, CommandScope.ISSUE) is False # Issue scope should not allow commit_comment - assert check_event_scope("commit_comment", {}, CommandScope.ISSUE) is False + event2 = sansio.Event({}, event="commit_comment", delivery_id="2") + assert check_event_scope(event2, CommandScope.ISSUE) is False def test_pull_request_field_none_treated_as_issue(self): # If pull_request field exists but is None, treat as issue - event_with_none_pr = {"issue": {"title": "Issue", "pull_request": None}} - assert ( - check_event_scope("issue_comment", event_with_none_pr, CommandScope.ISSUE) - is True - ) - assert ( - check_event_scope("issue_comment", event_with_none_pr, CommandScope.PR) - is False + event = sansio.Event( + {"issue": {"title": "Issue", "pull_request": None}}, + event="issue_comment", + delivery_id="1", ) + assert check_event_scope(event, CommandScope.ISSUE) is True + assert check_event_scope(event, CommandScope.PR) is False def test_missing_issue_data(self): # If issue data is missing entirely, default behavior - assert check_event_scope("issue_comment", {}, CommandScope.ISSUE) is True - assert check_event_scope("issue_comment", {}, CommandScope.PR) is False + event = sansio.Event({}, event="issue_comment", delivery_id="1") + assert check_event_scope(event, CommandScope.ISSUE) is True + assert check_event_scope(event, CommandScope.PR) is False From d7388043bd4887494c2ca1c36e787b75e6814fbb Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 17 Jun 2025 15:20:56 -0500 Subject: [PATCH 05/28] Rename commands module to mentions and CommandScope to MentionScope --- .../{commands.py => mentions.py} | 14 +++---- src/django_github_app/routing.py | 10 ++--- tests/{test_commands.py => test_mentions.py} | 40 +++++++++---------- tests/test_routing.py | 14 +++---- 4 files changed, 39 insertions(+), 39 deletions(-) rename src/django_github_app/{commands.py => mentions.py} (91%) rename tests/{test_commands.py => test_mentions.py} (88%) diff --git a/src/django_github_app/commands.py b/src/django_github_app/mentions.py similarity index 91% rename from src/django_github_app/commands.py rename to src/django_github_app/mentions.py index 4b16800..aeabc31 100644 --- a/src/django_github_app/commands.py +++ b/src/django_github_app/mentions.py @@ -12,24 +12,24 @@ class EventAction(NamedTuple): action: str -class CommandScope(str, Enum): +class MentionScope(str, Enum): COMMIT = "commit" ISSUE = "issue" PR = "pr" def get_events(self) -> list[EventAction]: match self: - case CommandScope.ISSUE: + case MentionScope.ISSUE: return [ EventAction("issue_comment", "created"), ] - case CommandScope.PR: + case MentionScope.PR: return [ EventAction("issue_comment", "created"), EventAction("pull_request_review_comment", "created"), EventAction("pull_request_review", "submitted"), ] - case CommandScope.COMMIT: + case MentionScope.COMMIT: return [ EventAction("commit_comment", "created"), ] @@ -92,7 +92,7 @@ def check_event_for_mention( return any(mention.command == command.lower() for mention in mentions) -def check_event_scope(event: sansio.Event, scope: CommandScope | None) -> bool: +def check_event_scope(event: sansio.Event, scope: MentionScope | None) -> bool: if scope is None: return True @@ -102,10 +102,10 @@ def check_event_scope(event: sansio.Event, scope: CommandScope | None) -> bool: is_pull_request = "pull_request" in issue and issue["pull_request"] is not None # If scope is ISSUE, we only want actual issues (not PRs) - if scope == CommandScope.ISSUE: + if scope == MentionScope.ISSUE: return not is_pull_request # If scope is PR, we only want pull requests - elif scope == CommandScope.PR: + elif scope == MentionScope.PR: return is_pull_request scope_events = scope.get_events() diff --git a/src/django_github_app/routing.py b/src/django_github_app/routing.py index a8a8824..f354815 100644 --- a/src/django_github_app/routing.py +++ b/src/django_github_app/routing.py @@ -14,9 +14,9 @@ from gidgethub.routing import Router as GidgetHubRouter from ._typing import override -from .commands import CommandScope -from .commands import check_event_for_mention -from .commands import check_event_scope +from .mentions import MentionScope +from .mentions import check_event_for_mention +from .mentions import check_event_scope AsyncCallback = Callable[..., Awaitable[None]] SyncCallback = Callable[..., None] @@ -26,7 +26,7 @@ class MentionHandlerBase(Protocol): _mention_command: str | None - _mention_scope: CommandScope | None + _mention_scope: MentionScope | None _mention_permission: str | None @@ -116,7 +116,7 @@ def sync_wrapper( wrapper._mention_scope = scope wrapper._mention_permission = permission - events = scope.get_events() if scope else CommandScope.all_events() + events = scope.get_events() if scope else MentionScope.all_events() for event_action in events: self.add( wrapper, event_action.event, action=event_action.action, **kwargs diff --git a/tests/test_commands.py b/tests/test_mentions.py similarity index 88% rename from tests/test_commands.py rename to tests/test_mentions.py index c4c2ae0..4a99f48 100644 --- a/tests/test_commands.py +++ b/tests/test_mentions.py @@ -2,10 +2,10 @@ from gidgethub import sansio -from django_github_app.commands import CommandScope -from django_github_app.commands import check_event_for_mention -from django_github_app.commands import check_event_scope -from django_github_app.commands import parse_mentions +from django_github_app.mentions import MentionScope +from django_github_app.mentions import check_event_for_mention +from django_github_app.mentions import check_event_scope +from django_github_app.mentions import parse_mentions class TestParseMentions: @@ -231,7 +231,7 @@ def test_issue_scope_on_issue_comment(self): issue_event = sansio.Event( {"issue": {"title": "Bug report"}}, event="issue_comment", delivery_id="1" ) - assert check_event_scope(issue_event, CommandScope.ISSUE) is True + assert check_event_scope(issue_event, MentionScope.ISSUE) is True # Issue comment on a pull request (has pull_request field) pr_event = sansio.Event( @@ -239,14 +239,14 @@ def test_issue_scope_on_issue_comment(self): event="issue_comment", delivery_id="2", ) - assert check_event_scope(pr_event, CommandScope.ISSUE) is False + assert check_event_scope(pr_event, MentionScope.ISSUE) is False def test_pr_scope_on_issue_comment(self): # Issue comment on an actual issue (no pull_request field) issue_event = sansio.Event( {"issue": {"title": "Bug report"}}, event="issue_comment", delivery_id="1" ) - assert check_event_scope(issue_event, CommandScope.PR) is False + assert check_event_scope(issue_event, MentionScope.PR) is False # Issue comment on a pull request (has pull_request field) pr_event = sansio.Event( @@ -254,42 +254,42 @@ def test_pr_scope_on_issue_comment(self): event="issue_comment", delivery_id="2", ) - assert check_event_scope(pr_event, CommandScope.PR) is True + assert check_event_scope(pr_event, MentionScope.PR) is True def test_pr_scope_allows_pr_specific_events(self): # PR scope should allow pull_request_review_comment event1 = sansio.Event({}, event="pull_request_review_comment", delivery_id="1") - assert check_event_scope(event1, CommandScope.PR) is True + assert check_event_scope(event1, MentionScope.PR) is True # PR scope should allow pull_request_review event2 = sansio.Event({}, event="pull_request_review", delivery_id="2") - assert check_event_scope(event2, CommandScope.PR) is True + assert check_event_scope(event2, MentionScope.PR) is True # PR scope should not allow commit_comment event3 = sansio.Event({}, event="commit_comment", delivery_id="3") - assert check_event_scope(event3, CommandScope.PR) is False + assert check_event_scope(event3, MentionScope.PR) is False def test_commit_scope_allows_commit_comment_only(self): # Commit scope should allow commit_comment event1 = sansio.Event({}, event="commit_comment", delivery_id="1") - assert check_event_scope(event1, CommandScope.COMMIT) is True + assert check_event_scope(event1, MentionScope.COMMIT) is True # Commit scope should not allow issue_comment event2 = sansio.Event({"issue": {}}, event="issue_comment", delivery_id="2") - assert check_event_scope(event2, CommandScope.COMMIT) is False + assert check_event_scope(event2, MentionScope.COMMIT) is False # Commit scope should not allow PR events event3 = sansio.Event({}, event="pull_request_review_comment", delivery_id="3") - assert check_event_scope(event3, CommandScope.COMMIT) is False + assert check_event_scope(event3, MentionScope.COMMIT) is False def test_issue_scope_disallows_non_issue_events(self): # Issue scope should not allow pull_request_review_comment event1 = sansio.Event({}, event="pull_request_review_comment", delivery_id="1") - assert check_event_scope(event1, CommandScope.ISSUE) is False + assert check_event_scope(event1, MentionScope.ISSUE) is False # Issue scope should not allow commit_comment event2 = sansio.Event({}, event="commit_comment", delivery_id="2") - assert check_event_scope(event2, CommandScope.ISSUE) is False + assert check_event_scope(event2, MentionScope.ISSUE) is False def test_pull_request_field_none_treated_as_issue(self): # If pull_request field exists but is None, treat as issue @@ -298,11 +298,11 @@ def test_pull_request_field_none_treated_as_issue(self): event="issue_comment", delivery_id="1", ) - assert check_event_scope(event, CommandScope.ISSUE) is True - assert check_event_scope(event, CommandScope.PR) is False + assert check_event_scope(event, MentionScope.ISSUE) is True + assert check_event_scope(event, MentionScope.PR) is False def test_missing_issue_data(self): # If issue data is missing entirely, default behavior event = sansio.Event({}, event="issue_comment", delivery_id="1") - assert check_event_scope(event, CommandScope.ISSUE) is True - assert check_event_scope(event, CommandScope.PR) is False + assert check_event_scope(event, MentionScope.ISSUE) is True + assert check_event_scope(event, MentionScope.PR) is False diff --git a/tests/test_routing.py b/tests/test_routing.py index 00b62c3..c2b0053 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -7,7 +7,7 @@ from django.http import JsonResponse from gidgethub import sansio -from django_github_app.commands import CommandScope +from django_github_app.mentions import MentionScope from django_github_app.github import SyncGitHubAPI from django_github_app.routing import GitHubRouter from django_github_app.views import BaseWebhookView @@ -157,7 +157,7 @@ def help_command(event, *args, **kwargs): def test_mention_with_scope(self, test_router): pr_handler_called = False - @test_router.mention(command="deploy", scope=CommandScope.PR) + @test_router.mention(command="deploy", scope=MentionScope.PR) def deploy_command(event, *args, **kwargs): nonlocal pr_handler_called pr_handler_called = True @@ -278,7 +278,7 @@ def test_scope_validation_issue_comment_on_issue(self, test_router): """Test that ISSUE scope works for actual issues.""" handler_called = False - @test_router.mention(command="issue-only", scope=CommandScope.ISSUE) + @test_router.mention(command="issue-only", scope=MentionScope.ISSUE) def issue_handler(event, *args, **kwargs): nonlocal handler_called handler_called = True @@ -301,7 +301,7 @@ def test_scope_validation_issue_comment_on_pr(self, test_router): """Test that ISSUE scope rejects PR comments.""" handler_called = False - @test_router.mention(command="issue-only", scope=CommandScope.ISSUE) + @test_router.mention(command="issue-only", scope=MentionScope.ISSUE) def issue_handler(event, *args, **kwargs): nonlocal handler_called handler_called = True @@ -328,7 +328,7 @@ def test_scope_validation_pr_scope_on_pr(self, test_router): """Test that PR scope works for pull requests.""" handler_called = False - @test_router.mention(command="pr-only", scope=CommandScope.PR) + @test_router.mention(command="pr-only", scope=MentionScope.PR) def pr_handler(event, *args, **kwargs): nonlocal handler_called handler_called = True @@ -355,7 +355,7 @@ def test_scope_validation_pr_scope_on_issue(self, test_router): """Test that PR scope rejects issue comments.""" handler_called = False - @test_router.mention(command="pr-only", scope=CommandScope.PR) + @test_router.mention(command="pr-only", scope=MentionScope.PR) def pr_handler(event, *args, **kwargs): nonlocal handler_called handler_called = True @@ -378,7 +378,7 @@ def test_scope_validation_commit_scope(self, test_router): """Test that COMMIT scope works for commit comments.""" handler_called = False - @test_router.mention(command="commit-only", scope=CommandScope.COMMIT) + @test_router.mention(command="commit-only", scope=MentionScope.COMMIT) def commit_handler(event, *args, **kwargs): nonlocal handler_called handler_called = True From 964098c90ef8e602ba39f82d4e6523ba4f1e4446 Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 17 Jun 2025 16:02:50 -0500 Subject: [PATCH 06/28] Add GitHub permission checking utilities --- src/django_github_app/permissions.py | 109 ++++++++++++++ tests/test_permissions.py | 212 +++++++++++++++++++++++++++ 2 files changed, 321 insertions(+) create mode 100644 src/django_github_app/permissions.py create mode 100644 tests/test_permissions.py diff --git a/src/django_github_app/permissions.py b/src/django_github_app/permissions.py new file mode 100644 index 0000000..6891cdd --- /dev/null +++ b/src/django_github_app/permissions.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from enum import Enum +from typing import NamedTuple + +import cachetools +import gidgethub + +from django_github_app.github import AsyncGitHubAPI +from django_github_app.github import SyncGitHubAPI + + +class PermissionCacheKey(NamedTuple): + owner: str + repo: str + username: str + + +class Permission(int, Enum): + NONE = 0 + READ = 1 + TRIAGE = 2 + WRITE = 3 + MAINTAIN = 4 + ADMIN = 5 + + @classmethod + def from_string(cls, permission: str) -> Permission: + permission_map = { + "none": cls.NONE, + "read": cls.READ, + "triage": cls.TRIAGE, + "write": cls.WRITE, + "maintain": cls.MAINTAIN, + "admin": cls.ADMIN, + } + + normalized = permission.lower().strip() + if normalized not in permission_map: + raise ValueError(f"Unknown permission level: {permission}") + + return permission_map[normalized] + + +cache: cachetools.LRUCache[PermissionCacheKey, Permission] = cachetools.LRUCache( + maxsize=128 +) + + +async def aget_user_permission( + gh: AsyncGitHubAPI, owner: str, repo: str, username: str +) -> Permission: + cache_key = PermissionCacheKey(owner, repo, username) + + if cache_key in cache: + return cache[cache_key] + + permission = Permission.NONE + + try: + # Check if user is a collaborator and get their permission + data = await gh.getitem( + f"/repos/{owner}/{repo}/collaborators/{username}/permission" + ) + permission_str = data.get("permission", "none") + permission = Permission.from_string(permission_str) + except gidgethub.HTTPException as e: + if e.status_code == 404: + # User is not a collaborator, they have read permission if repo is public + # Check if repo is public + try: + repo_data = await gh.getitem(f"/repos/{owner}/{repo}") + if not repo_data.get("private", True): + permission = Permission.READ + except gidgethub.HTTPException: + pass + + cache[cache_key] = permission + return permission + + +def get_user_permission( + gh: SyncGitHubAPI, owner: str, repo: str, username: str +) -> Permission: + cache_key = PermissionCacheKey(owner, repo, username) + + if cache_key in cache: + return cache[cache_key] + + permission = Permission.NONE + + try: + # Check if user is a collaborator and get their permission + data = gh.getitem(f"/repos/{owner}/{repo}/collaborators/{username}/permission") + permission_str = data.get("permission", "none") + permission = Permission.from_string(permission_str) + except gidgethub.HTTPException as e: + if e.status_code == 404: + # User is not a collaborator, they have read permission if repo is public + # Check if repo is public + try: + repo_data = gh.getitem(f"/repos/{owner}/{repo}") + if not repo_data.get("private", True): + permission = Permission.READ + except gidgethub.HTTPException: + pass + + cache[cache_key] = permission + return permission diff --git a/tests/test_permissions.py b/tests/test_permissions.py new file mode 100644 index 0000000..cce1d67 --- /dev/null +++ b/tests/test_permissions.py @@ -0,0 +1,212 @@ +"""Tests for GitHub permission checking utilities.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, Mock, create_autospec + +import gidgethub +import pytest + +from django_github_app.github import AsyncGitHubAPI, SyncGitHubAPI +from django_github_app.permissions import ( + Permission, + aget_user_permission, + get_user_permission, + cache, +) + + +@pytest.fixture(autouse=True) +def clear_cache(): + """Clear the permission cache before and after each test.""" + cache.clear() + yield + cache.clear() + + +class TestPermission: + """Test Permission enum functionality.""" + + def test_permission_ordering(self): + """Test that permission levels are correctly ordered.""" + assert Permission.NONE < Permission.READ + assert Permission.READ < Permission.TRIAGE + assert Permission.TRIAGE < Permission.WRITE + assert Permission.WRITE < Permission.MAINTAIN + assert Permission.MAINTAIN < Permission.ADMIN + + assert Permission.ADMIN > Permission.WRITE + assert Permission.WRITE >= Permission.WRITE + assert Permission.READ <= Permission.TRIAGE + + def test_from_string(self): + """Test converting string permissions to enum.""" + assert Permission.from_string("read") == Permission.READ + assert Permission.from_string("READ") == Permission.READ + assert Permission.from_string(" admin ") == Permission.ADMIN + assert Permission.from_string("triage") == Permission.TRIAGE + assert Permission.from_string("write") == Permission.WRITE + assert Permission.from_string("maintain") == Permission.MAINTAIN + assert Permission.from_string("none") == Permission.NONE + + def test_from_string_invalid(self): + """Test that invalid permission strings raise ValueError.""" + with pytest.raises(ValueError, match="Unknown permission level: invalid"): + Permission.from_string("invalid") + + with pytest.raises(ValueError, match="Unknown permission level: owner"): + Permission.from_string("owner") + + +@pytest.mark.asyncio +class TestGetUserPermission: + """Test aget_user_permission function.""" + + async def test_collaborator_with_admin_permission(self): + """Test getting permission for a collaborator with admin access.""" + gh = create_autospec(AsyncGitHubAPI, instance=True) + gh.getitem = AsyncMock(return_value={"permission": "admin"}) + + permission = await aget_user_permission(gh, "owner", "repo", "user") + + assert permission == Permission.ADMIN + gh.getitem.assert_called_once_with( + "/repos/owner/repo/collaborators/user/permission" + ) + + async def test_collaborator_with_write_permission(self): + """Test getting permission for a collaborator with write access.""" + gh = create_autospec(AsyncGitHubAPI, instance=True) + gh.getitem = AsyncMock(return_value={"permission": "write"}) + + permission = await aget_user_permission(gh, "owner", "repo", "user") + + assert permission == Permission.WRITE + + async def test_non_collaborator_public_repo(self): + """Test non-collaborator has read access to public repo.""" + gh = create_autospec(AsyncGitHubAPI, instance=True) + # First call returns 404 (not a collaborator) + gh.getitem = AsyncMock(side_effect=[ + gidgethub.HTTPException(404, "Not found", {}), + {"private": False}, # Repo is public + ]) + + permission = await aget_user_permission(gh, "owner", "repo", "user") + + assert permission == Permission.READ + assert gh.getitem.call_count == 2 + gh.getitem.assert_any_call("/repos/owner/repo/collaborators/user/permission") + gh.getitem.assert_any_call("/repos/owner/repo") + + async def test_non_collaborator_private_repo(self): + """Test non-collaborator has no access to private repo.""" + gh = create_autospec(AsyncGitHubAPI, instance=True) + # First call returns 404 (not a collaborator) + gh.getitem = AsyncMock(side_effect=[ + gidgethub.HTTPException(404, "Not found", {}), + {"private": True}, # Repo is private + ]) + + permission = await aget_user_permission(gh, "owner", "repo", "user") + + assert permission == Permission.NONE + + async def test_api_error_returns_none_permission(self): + """Test that API errors default to no permission.""" + gh = create_autospec(AsyncGitHubAPI, instance=True) + gh.getitem = AsyncMock(side_effect=gidgethub.HTTPException( + 500, "Server error", {} + )) + + permission = await aget_user_permission(gh, "owner", "repo", "user") + + assert permission == Permission.NONE + + async def test_missing_permission_field(self): + """Test handling response without permission field.""" + gh = create_autospec(AsyncGitHubAPI, instance=True) + gh.getitem = AsyncMock(return_value={}) # No permission field + + permission = await aget_user_permission(gh, "owner", "repo", "user") + + assert permission == Permission.NONE + + +class TestGetUserPermissionSync: + """Test synchronous get_user_permission function.""" + + def test_collaborator_with_permission(self): + """Test getting permission for a collaborator.""" + gh = create_autospec(SyncGitHubAPI, instance=True) + gh.getitem = Mock(return_value={"permission": "maintain"}) + + permission = get_user_permission(gh, "owner", "repo", "user") + + assert permission == Permission.MAINTAIN + gh.getitem.assert_called_once_with( + "/repos/owner/repo/collaborators/user/permission" + ) + + def test_non_collaborator_public_repo(self): + """Test non-collaborator has read access to public repo.""" + gh = create_autospec(SyncGitHubAPI, instance=True) + # First call returns 404 (not a collaborator) + gh.getitem = Mock(side_effect=[ + gidgethub.HTTPException(404, "Not found", {}), + {"private": False}, # Repo is public + ]) + + permission = get_user_permission(gh, "owner", "repo", "user") + + assert permission == Permission.READ + + +@pytest.mark.asyncio +class TestPermissionCaching: + """Test permission caching functionality.""" + + async def test_cache_hit(self): + """Test that cache returns stored values.""" + gh = create_autospec(AsyncGitHubAPI, instance=True) + gh.getitem = AsyncMock(return_value={"permission": "write"}) + + # First call should hit the API + perm1 = await aget_user_permission(gh, "owner", "repo", "user") + assert perm1 == Permission.WRITE + assert gh.getitem.call_count == 1 + + # Second call should use cache + perm2 = await aget_user_permission(gh, "owner", "repo", "user") + assert perm2 == Permission.WRITE + assert gh.getitem.call_count == 1 # No additional API call + + async def test_cache_different_users(self): + """Test that cache handles different users correctly.""" + gh = create_autospec(AsyncGitHubAPI, instance=True) + gh.getitem = AsyncMock(side_effect=[ + {"permission": "write"}, + {"permission": "admin"}, + ]) + + perm1 = await aget_user_permission(gh, "owner", "repo", "user1") + perm2 = await aget_user_permission(gh, "owner", "repo", "user2") + + assert perm1 == Permission.WRITE + assert perm2 == Permission.ADMIN + assert gh.getitem.call_count == 2 + + def test_sync_cache_hit(self): + """Test that sync version uses cache.""" + gh = create_autospec(SyncGitHubAPI, instance=True) + gh.getitem = Mock(return_value={"permission": "read"}) + + # First call should hit the API + perm1 = get_user_permission(gh, "owner", "repo", "user") + assert perm1 == Permission.READ + assert gh.getitem.call_count == 1 + + # Second call should use cache + perm2 = get_user_permission(gh, "owner", "repo", "user") + assert perm2 == Permission.READ + assert gh.getitem.call_count == 1 # No additional API call \ No newline at end of file From 391f199ed4605269b36032a986e26d9e134763ed Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 17 Jun 2025 20:41:05 -0500 Subject: [PATCH 07/28] Integrate permission checking into mention decorator --- src/django_github_app/permissions.py | 120 +++++++++++++++-- src/django_github_app/routing.py | 52 +++++++- tests/conftest.py | 25 ++++ tests/test_permissions.py | 184 +++++++++++++-------------- tests/test_routing.py | 167 +++++++++++++++++++++++- 5 files changed, 435 insertions(+), 113 deletions(-) diff --git a/src/django_github_app/permissions.py b/src/django_github_app/permissions.py index 6891cdd..c1cce02 100644 --- a/src/django_github_app/permissions.py +++ b/src/django_github_app/permissions.py @@ -5,17 +5,12 @@ import cachetools import gidgethub +from gidgethub import sansio from django_github_app.github import AsyncGitHubAPI from django_github_app.github import SyncGitHubAPI -class PermissionCacheKey(NamedTuple): - owner: str - repo: str - username: str - - class Permission(int, Enum): NONE = 0 READ = 1 @@ -47,6 +42,12 @@ def from_string(cls, permission: str) -> Permission: ) +class PermissionCacheKey(NamedTuple): + owner: str + repo: str + username: str + + async def aget_user_permission( gh: AsyncGitHubAPI, owner: str, repo: str, username: str ) -> Permission: @@ -92,7 +93,7 @@ def get_user_permission( try: # Check if user is a collaborator and get their permission data = gh.getitem(f"/repos/{owner}/{repo}/collaborators/{username}/permission") - permission_str = data.get("permission", "none") + permission_str = data.get("permission", "none") # type: ignore[attr-defined] permission = Permission.from_string(permission_str) except gidgethub.HTTPException as e: if e.status_code == 404: @@ -100,10 +101,113 @@ def get_user_permission( # Check if repo is public try: repo_data = gh.getitem(f"/repos/{owner}/{repo}") - if not repo_data.get("private", True): + if not repo_data.get("private", True): # type: ignore[attr-defined] permission = Permission.READ except gidgethub.HTTPException: pass cache[cache_key] = permission return permission + + +class EventInfo(NamedTuple): + comment_author: str | None + owner: str | None + repo: str | None + + @classmethod + def from_event(cls, event: sansio.Event) -> EventInfo: + comment_author = None + owner = None + repo = None + + if "comment" in event.data: + comment_author = event.data["comment"]["user"]["login"] + + if "repository" in event.data: + owner = event.data["repository"]["owner"]["login"] + repo = event.data["repository"]["name"] + + return cls(comment_author=comment_author, owner=owner, repo=repo) + + +class PermissionCheck(NamedTuple): + has_permission: bool + error_message: str | None + + +PERMISSION_CHECK_ERROR_MESSAGE = """ +❌ **Permission Denied** + +@{comment_author}, you need at least **{required_permission}** permission to use this command. + +Your current permission level: **{user_permission}** +""" + + +async def acheck_mention_permission( + event: sansio.Event, gh: AsyncGitHubAPI, required_permission: Permission +) -> PermissionCheck: + comment_author, owner, repo = EventInfo.from_event(event) + + if not (comment_author and owner and repo): + return PermissionCheck(has_permission=False, error_message=None) + + user_permission = await aget_user_permission(gh, owner, repo, comment_author) + + if user_permission >= required_permission: + return PermissionCheck(has_permission=True, error_message=None) + + return PermissionCheck( + has_permission=False, + error_message=PERMISSION_CHECK_ERROR_MESSAGE.format( + comment_author=comment_author, + required_permission=required_permission.name.lower(), + user_permission=user_permission.name.lower(), + ), + ) + + +def check_mention_permission( + event: sansio.Event, gh: SyncGitHubAPI, required_permission: Permission +) -> PermissionCheck: + comment_author, owner, repo = EventInfo.from_event(event) + + if not (comment_author and owner and repo): + return PermissionCheck(has_permission=False, error_message=None) + + user_permission = get_user_permission(gh, owner, repo, comment_author) + + if user_permission >= required_permission: + return PermissionCheck(has_permission=True, error_message=None) + + return PermissionCheck( + has_permission=False, + error_message=PERMISSION_CHECK_ERROR_MESSAGE.format( + comment_author=comment_author, + required_permission=required_permission.name.lower(), + user_permission=user_permission.name.lower(), + ), + ) + + +def get_comment_post_url(event: sansio.Event) -> str | None: + if event.data.get("action") != "created": + return None + + _, owner, repo = EventInfo.from_event(event) + + if not (owner and repo): + return None + + if "issue" in event.data: + issue_number = event.data["issue"]["number"] + return f"/repos/{owner}/{repo}/issues/{issue_number}/comments" + elif "pull_request" in event.data: + pr_number = event.data["pull_request"]["number"] + return f"/repos/{owner}/{repo}/issues/{pr_number}/comments" + elif "commit_sha" in event.data: + commit_sha = event.data["commit_sha"] + return f"/repos/{owner}/{repo}/commits/{commit_sha}/comments" + + return None diff --git a/src/django_github_app/routing.py b/src/django_github_app/routing.py index f354815..1c082d6 100644 --- a/src/django_github_app/routing.py +++ b/src/django_github_app/routing.py @@ -14,9 +14,15 @@ from gidgethub.routing import Router as GidgetHubRouter from ._typing import override +from .github import AsyncGitHubAPI +from .github import SyncGitHubAPI from .mentions import MentionScope from .mentions import check_event_for_mention from .mentions import check_event_scope +from .permissions import Permission +from .permissions import acheck_mention_permission +from .permissions import check_mention_permission +from .permissions import get_comment_post_url AsyncCallback = Callable[..., Awaitable[None]] SyncCallback = Callable[..., None] @@ -76,7 +82,7 @@ def decorator(func: CB) -> CB: @wraps(func) async def async_wrapper( - event: sansio.Event, *args: Any, **wrapper_kwargs: Any + event: sansio.Event, gh: AsyncGitHubAPI, *args: Any, **kwargs: Any ) -> None: # TODO: Get actual bot username from installation/app data username = "bot" # Placeholder @@ -87,12 +93,29 @@ async def async_wrapper( if not check_event_scope(event, scope): return - # TODO: Check permissions. For now, just call through. - await func(event, *args, **wrapper_kwargs) # type: ignore[func-returns-value] + # Check permissions if required + if permission is not None: + required_perm = Permission.from_string(permission) + permission_check = await acheck_mention_permission( + event, gh, required_perm + ) + + if not permission_check.has_permission: + # Post error comment if we have an error message + if permission_check.error_message: + comment_url = get_comment_post_url(event) + if comment_url: + await gh.post( + comment_url, + data={"body": permission_check.error_message}, + ) + return + + await func(event, gh, *args, **kwargs) # type: ignore[func-returns-value] @wraps(func) def sync_wrapper( - event: sansio.Event, *args: Any, **wrapper_kwargs: Any + event: sansio.Event, gh: SyncGitHubAPI, *args: Any, **kwargs: Any ) -> None: # TODO: Get actual bot username from installation/app data username = "bot" # Placeholder @@ -103,8 +126,25 @@ def sync_wrapper( if not check_event_scope(event, scope): return - # TODO: Check permissions. For now, just call through. - func(event, *args, **wrapper_kwargs) + # Check permissions if required + if permission is not None: + required_perm = Permission.from_string(permission) + permission_check = check_mention_permission( + event, gh, required_perm + ) + + if not permission_check.has_permission: + # Post error comment if we have an error message + if permission_check.error_message: + comment_url = get_comment_post_url(event) + if comment_url: + gh.post( # type: ignore[unused-coroutine] + comment_url, + data={"body": permission_check.error_message}, + ) + return + + func(event, gh, *args, **kwargs) wrapper: MentionHandler if iscoroutinefunction(func): diff --git a/tests/conftest.py b/tests/conftest.py index 70bf9cb..234e119 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -150,6 +150,31 @@ async def mock_getiter(*args, **kwargs): return _get_mock_github_api +@pytest.fixture +def get_mock_github_api_sync(): + def _get_mock_github_api_sync(return_data): + from django_github_app.github import SyncGitHubAPI + + mock_api = MagicMock(spec=SyncGitHubAPI) + + def mock_getitem(*args, **kwargs): + return return_data + + def mock_getiter(*args, **kwargs): + yield from return_data + + def mock_post(*args, **kwargs): + pass + + mock_api.getitem = mock_getitem + mock_api.getiter = mock_getiter + mock_api.post = mock_post + + return mock_api + + return _get_mock_github_api_sync + + @pytest.fixture def installation(get_mock_github_api, baker): installation = baker.make( diff --git a/tests/test_permissions.py b/tests/test_permissions.py index cce1d67..0db1af8 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -1,212 +1,206 @@ -"""Tests for GitHub permission checking utilities.""" - from __future__ import annotations -from unittest.mock import AsyncMock, Mock, create_autospec +from unittest.mock import AsyncMock +from unittest.mock import Mock +from unittest.mock import create_autospec import gidgethub import pytest -from django_github_app.github import AsyncGitHubAPI, SyncGitHubAPI -from django_github_app.permissions import ( - Permission, - aget_user_permission, - get_user_permission, - cache, -) +from django_github_app.github import AsyncGitHubAPI +from django_github_app.github import SyncGitHubAPI +from django_github_app.permissions import Permission +from django_github_app.permissions import aget_user_permission +from django_github_app.permissions import cache +from django_github_app.permissions import get_user_permission @pytest.fixture(autouse=True) def clear_cache(): - """Clear the permission cache before and after each test.""" cache.clear() yield cache.clear() class TestPermission: - """Test Permission enum functionality.""" - def test_permission_ordering(self): - """Test that permission levels are correctly ordered.""" assert Permission.NONE < Permission.READ assert Permission.READ < Permission.TRIAGE assert Permission.TRIAGE < Permission.WRITE assert Permission.WRITE < Permission.MAINTAIN assert Permission.MAINTAIN < Permission.ADMIN - + assert Permission.ADMIN > Permission.WRITE assert Permission.WRITE >= Permission.WRITE assert Permission.READ <= Permission.TRIAGE - - def test_from_string(self): - """Test converting string permissions to enum.""" - assert Permission.from_string("read") == Permission.READ - assert Permission.from_string("READ") == Permission.READ - assert Permission.from_string(" admin ") == Permission.ADMIN - assert Permission.from_string("triage") == Permission.TRIAGE - assert Permission.from_string("write") == Permission.WRITE - assert Permission.from_string("maintain") == Permission.MAINTAIN - assert Permission.from_string("none") == Permission.NONE - + + @pytest.mark.parametrize( + "permission_str,expected", + [ + ("read", Permission.READ), + ("Read", Permission.READ), + ("READ", Permission.READ), + (" read ", Permission.READ), + ("triage", Permission.TRIAGE), + ("write", Permission.WRITE), + ("maintain", Permission.MAINTAIN), + ("admin", Permission.ADMIN), + ("none", Permission.NONE), + ], + ) + def test_from_string(self, permission_str, expected): + assert Permission.from_string(permission_str) == expected + def test_from_string_invalid(self): - """Test that invalid permission strings raise ValueError.""" with pytest.raises(ValueError, match="Unknown permission level: invalid"): Permission.from_string("invalid") - + with pytest.raises(ValueError, match="Unknown permission level: owner"): Permission.from_string("owner") @pytest.mark.asyncio class TestGetUserPermission: - """Test aget_user_permission function.""" - async def test_collaborator_with_admin_permission(self): - """Test getting permission for a collaborator with admin access.""" gh = create_autospec(AsyncGitHubAPI, instance=True) gh.getitem = AsyncMock(return_value={"permission": "admin"}) - + permission = await aget_user_permission(gh, "owner", "repo", "user") - + assert permission == Permission.ADMIN gh.getitem.assert_called_once_with( "/repos/owner/repo/collaborators/user/permission" ) - + async def test_collaborator_with_write_permission(self): - """Test getting permission for a collaborator with write access.""" gh = create_autospec(AsyncGitHubAPI, instance=True) gh.getitem = AsyncMock(return_value={"permission": "write"}) - + permission = await aget_user_permission(gh, "owner", "repo", "user") - + assert permission == Permission.WRITE - + async def test_non_collaborator_public_repo(self): - """Test non-collaborator has read access to public repo.""" gh = create_autospec(AsyncGitHubAPI, instance=True) # First call returns 404 (not a collaborator) - gh.getitem = AsyncMock(side_effect=[ - gidgethub.HTTPException(404, "Not found", {}), - {"private": False}, # Repo is public - ]) - + gh.getitem = AsyncMock( + side_effect=[ + gidgethub.HTTPException(404, "Not found", {}), + {"private": False}, # Repo is public + ] + ) + permission = await aget_user_permission(gh, "owner", "repo", "user") - + assert permission == Permission.READ assert gh.getitem.call_count == 2 gh.getitem.assert_any_call("/repos/owner/repo/collaborators/user/permission") gh.getitem.assert_any_call("/repos/owner/repo") - + async def test_non_collaborator_private_repo(self): - """Test non-collaborator has no access to private repo.""" gh = create_autospec(AsyncGitHubAPI, instance=True) # First call returns 404 (not a collaborator) - gh.getitem = AsyncMock(side_effect=[ - gidgethub.HTTPException(404, "Not found", {}), - {"private": True}, # Repo is private - ]) - + gh.getitem = AsyncMock( + side_effect=[ + gidgethub.HTTPException(404, "Not found", {}), + {"private": True}, # Repo is private + ] + ) + permission = await aget_user_permission(gh, "owner", "repo", "user") - + assert permission == Permission.NONE - + async def test_api_error_returns_none_permission(self): - """Test that API errors default to no permission.""" gh = create_autospec(AsyncGitHubAPI, instance=True) - gh.getitem = AsyncMock(side_effect=gidgethub.HTTPException( - 500, "Server error", {} - )) - + gh.getitem = AsyncMock( + side_effect=gidgethub.HTTPException(500, "Server error", {}) + ) + permission = await aget_user_permission(gh, "owner", "repo", "user") - + assert permission == Permission.NONE - + async def test_missing_permission_field(self): - """Test handling response without permission field.""" gh = create_autospec(AsyncGitHubAPI, instance=True) gh.getitem = AsyncMock(return_value={}) # No permission field - + permission = await aget_user_permission(gh, "owner", "repo", "user") - + assert permission == Permission.NONE class TestGetUserPermissionSync: - """Test synchronous get_user_permission function.""" - def test_collaborator_with_permission(self): - """Test getting permission for a collaborator.""" gh = create_autospec(SyncGitHubAPI, instance=True) gh.getitem = Mock(return_value={"permission": "maintain"}) - + permission = get_user_permission(gh, "owner", "repo", "user") - + assert permission == Permission.MAINTAIN gh.getitem.assert_called_once_with( "/repos/owner/repo/collaborators/user/permission" ) - + def test_non_collaborator_public_repo(self): - """Test non-collaborator has read access to public repo.""" gh = create_autospec(SyncGitHubAPI, instance=True) # First call returns 404 (not a collaborator) - gh.getitem = Mock(side_effect=[ - gidgethub.HTTPException(404, "Not found", {}), - {"private": False}, # Repo is public - ]) - + gh.getitem = Mock( + side_effect=[ + gidgethub.HTTPException(404, "Not found", {}), + {"private": False}, # Repo is public + ] + ) + permission = get_user_permission(gh, "owner", "repo", "user") - + assert permission == Permission.READ -@pytest.mark.asyncio class TestPermissionCaching: - """Test permission caching functionality.""" - + @pytest.mark.asyncio async def test_cache_hit(self): - """Test that cache returns stored values.""" gh = create_autospec(AsyncGitHubAPI, instance=True) gh.getitem = AsyncMock(return_value={"permission": "write"}) - + # First call should hit the API perm1 = await aget_user_permission(gh, "owner", "repo", "user") assert perm1 == Permission.WRITE assert gh.getitem.call_count == 1 - + # Second call should use cache perm2 = await aget_user_permission(gh, "owner", "repo", "user") assert perm2 == Permission.WRITE assert gh.getitem.call_count == 1 # No additional API call - + + @pytest.mark.asyncio async def test_cache_different_users(self): - """Test that cache handles different users correctly.""" gh = create_autospec(AsyncGitHubAPI, instance=True) - gh.getitem = AsyncMock(side_effect=[ - {"permission": "write"}, - {"permission": "admin"}, - ]) - + gh.getitem = AsyncMock( + side_effect=[ + {"permission": "write"}, + {"permission": "admin"}, + ] + ) + perm1 = await aget_user_permission(gh, "owner", "repo", "user1") perm2 = await aget_user_permission(gh, "owner", "repo", "user2") - + assert perm1 == Permission.WRITE assert perm2 == Permission.ADMIN assert gh.getitem.call_count == 2 - + def test_sync_cache_hit(self): """Test that sync version uses cache.""" gh = create_autospec(SyncGitHubAPI, instance=True) gh.getitem = Mock(return_value={"permission": "read"}) - + # First call should hit the API perm1 = get_user_permission(gh, "owner", "repo", "user") assert perm1 == Permission.READ assert gh.getitem.call_count == 1 - + # Second call should use cache perm2 = get_user_permission(gh, "owner", "repo", "user") assert perm2 == Permission.READ - assert gh.getitem.call_count == 1 # No additional API call \ No newline at end of file + assert gh.getitem.call_count == 1 # No additional API call diff --git a/tests/test_routing.py b/tests/test_routing.py index c2b0053..6c99553 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -2,17 +2,26 @@ import asyncio +import gidgethub import pytest from django.http import HttpRequest from django.http import JsonResponse from gidgethub import sansio -from django_github_app.mentions import MentionScope from django_github_app.github import SyncGitHubAPI +from django_github_app.mentions import MentionScope +from django_github_app.permissions import cache from django_github_app.routing import GitHubRouter from django_github_app.views import BaseWebhookView +@pytest.fixture(autouse=True) +def clear_permission_cache(): + cache.clear() + yield + cache.clear() + + @pytest.fixture(autouse=True) def test_router(): import django_github_app.views @@ -182,7 +191,7 @@ def deploy_command(event, *args, **kwargs): assert not pr_handler_called - def test_mention_with_permission(self, test_router): + def test_mention_with_permission(self, test_router, get_mock_github_api_sync): handler_called = False @test_router.mention(command="delete", permission="admin") @@ -191,11 +200,20 @@ def delete_command(event, *args, **kwargs): handler_called = True event = sansio.Event( - {"action": "created", "comment": {"body": "@bot delete"}}, + { + "action": "created", + "comment": {"body": "@bot delete", "user": {"login": "testuser"}}, + "issue": { + "number": 123 + }, # Added issue field required for issue_comment events + "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, + }, event="issue_comment", delivery_id="123", ) - test_router.dispatch(event, None) + # Mock the permission check to return admin permission + mock_gh = get_mock_github_api_sync({"permission": "admin"}) + test_router.dispatch(event, mock_gh) assert handler_called @@ -447,3 +465,144 @@ def all_handler(event, *args, **kwargs): test_router.dispatch(event, None) assert call_count == 3 + + def test_mention_permission_denied(self, test_router, get_mock_github_api_sync): + """Test that permission denial posts error comment.""" + handler_called = False + posted_comment = None + + @test_router.mention(command="admin-only", permission="admin") + def admin_command(event, *args, **kwargs): + nonlocal handler_called + handler_called = True + + event = sansio.Event( + { + "action": "created", + "comment": {"body": "@bot admin-only", "user": {"login": "testuser"}}, + "issue": {"number": 123}, + "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, + }, + event="issue_comment", + delivery_id="123", + ) + + # Mock the permission check to return write permission (less than admin) + mock_gh = get_mock_github_api_sync({"permission": "write"}) + + # Capture the posted comment + def capture_post(url, data=None, **kwargs): + nonlocal posted_comment + posted_comment = data.get("body") if data else None + + mock_gh.post = capture_post + + test_router.dispatch(event, mock_gh) + + # Handler should not be called + assert not handler_called + # Error comment should be posted + assert posted_comment is not None + assert "Permission Denied" in posted_comment + assert "admin" in posted_comment + assert "write" in posted_comment + assert "@testuser" in posted_comment + + def test_mention_permission_denied_no_permission( + self, test_router, get_mock_github_api_sync + ): + """Test permission denial when user has no permission.""" + handler_called = False + posted_comment = None + + @test_router.mention(command="write-required", permission="write") + def write_command(event, *args, **kwargs): + nonlocal handler_called + handler_called = True + + event = sansio.Event( + { + "action": "created", + "comment": { + "body": "@bot write-required", + "user": {"login": "stranger"}, + }, + "issue": {"number": 456}, + "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, + }, + event="issue_comment", + delivery_id="456", + ) + + # Mock returns 404 for non-collaborator + mock_gh = get_mock_github_api_sync({}) # Empty dict as we'll override getitem + mock_gh.getitem.side_effect = [ + gidgethub.HTTPException(404, "Not found", {}), # User is not a collaborator + {"private": True}, # Repo is private + ] + + # Capture the posted comment + def capture_post(url, data=None, **kwargs): + nonlocal posted_comment + posted_comment = data.get("body") if data else None + + mock_gh.post = capture_post + + test_router.dispatch(event, mock_gh) + + # Handler should not be called + assert not handler_called + # Error comment should be posted + assert posted_comment is not None + assert "Permission Denied" in posted_comment + assert "write" in posted_comment + assert "none" in posted_comment # User has no permission + assert "@stranger" in posted_comment + + @pytest.mark.asyncio + async def test_async_mention_permission_denied( + self, test_router, get_mock_github_api + ): + """Test async permission denial posts error comment.""" + handler_called = False + posted_comment = None + + @test_router.mention(command="maintain-only", permission="maintain") + async def maintain_command(event, *args, **kwargs): + nonlocal handler_called + handler_called = True + + event = sansio.Event( + { + "action": "created", + "comment": { + "body": "@bot maintain-only", + "user": {"login": "contributor"}, + }, + "issue": {"number": 789}, + "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, + }, + event="issue_comment", + delivery_id="789", + ) + + # Mock the permission check to return triage permission (less than maintain) + mock_gh = get_mock_github_api({"permission": "triage"}) + + # Capture the posted comment + async def capture_post(url, data=None, **kwargs): + nonlocal posted_comment + posted_comment = data.get("body") if data else None + + mock_gh.post = capture_post + + await test_router.adispatch(event, mock_gh) + + # Handler should not be called + assert not handler_called + # Error comment should be posted + assert posted_comment is not None + assert "Permission Denied" in posted_comment + assert "maintain" in posted_comment + assert "triage" in posted_comment + assert "@contributor" in posted_comment From c6fe62a6f7864eb26937a9fdf600d99260515ed8 Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 17 Jun 2025 22:49:36 -0500 Subject: [PATCH 08/28] Refactor mention decorator from gatekeeper to enrichment pattern --- src/django_github_app/mentions.py | 42 ++-- src/django_github_app/permissions.py | 80 +++----- src/django_github_app/routing.py | 60 ++---- tests/test_mentions.py | 224 ++++++++++++-------- tests/test_routing.py | 297 +++++++++++++++++---------- 5 files changed, 402 insertions(+), 301 deletions(-) diff --git a/src/django_github_app/mentions.py b/src/django_github_app/mentions.py index aeabc31..c6b569d 100644 --- a/src/django_github_app/mentions.py +++ b/src/django_github_app/mentions.py @@ -1,11 +1,14 @@ from __future__ import annotations import re +from dataclasses import dataclass from enum import Enum from typing import NamedTuple from gidgethub import sansio +from .permissions import Permission + class EventAction(NamedTuple): event: str @@ -43,6 +46,13 @@ def all_events(cls) -> list[EventAction]: ) +@dataclass +class MentionContext: + commands: list[str] + user_permission: Permission | None + scope: MentionScope | None + + class MentionMatch(NamedTuple): mention: str command: str | None @@ -53,7 +63,9 @@ class MentionMatch(NamedTuple): QUOTE_PATTERN = re.compile(r"^\s*>.*$", re.MULTILINE) -def parse_mentions(text: str, username: str) -> list[MentionMatch]: +def parse_mentions(event: sansio.Event, username: str) -> list[MentionMatch]: + text = event.data.get("comment", {}).get("body", "") + if not text: return [] @@ -77,11 +89,15 @@ def parse_mentions(text: str, username: str) -> list[MentionMatch]: return mentions +def get_commands(event: sansio.Event, username: str) -> list[str]: + mentions = parse_mentions(event, username) + return [m.command for m in mentions if m.command] + + def check_event_for_mention( event: sansio.Event, command: str | None, username: str ) -> bool: - comment = event.data.get("comment", {}).get("body", "") - mentions = parse_mentions(comment, username) + mentions = parse_mentions(event, username) if not mentions: return False @@ -92,21 +108,15 @@ def check_event_for_mention( return any(mention.command == command.lower() for mention in mentions) -def check_event_scope(event: sansio.Event, scope: MentionScope | None) -> bool: - if scope is None: - return True - - # For issue_comment events, we need to distinguish between issues and PRs +def get_event_scope(event: sansio.Event) -> MentionScope | None: if event.event == "issue_comment": issue = event.data.get("issue", {}) is_pull_request = "pull_request" in issue and issue["pull_request"] is not None + return MentionScope.PR if is_pull_request else MentionScope.ISSUE - # If scope is ISSUE, we only want actual issues (not PRs) - if scope == MentionScope.ISSUE: - return not is_pull_request - # If scope is PR, we only want pull requests - elif scope == MentionScope.PR: - return is_pull_request + for scope in MentionScope: + scope_events = scope.get_events() + if any(event_action.event == event.event for event_action in scope_events): + return scope - scope_events = scope.get_events() - return any(event_action.event == event.event for event_action in scope_events) + return None diff --git a/src/django_github_app/permissions.py b/src/django_github_app/permissions.py index c1cce02..31735a1 100644 --- a/src/django_github_app/permissions.py +++ b/src/django_github_app/permissions.py @@ -133,81 +133,47 @@ def from_event(cls, event: sansio.Event) -> EventInfo: class PermissionCheck(NamedTuple): has_permission: bool - error_message: str | None -PERMISSION_CHECK_ERROR_MESSAGE = """ -❌ **Permission Denied** +async def aget_user_permission_from_event( + event: sansio.Event, gh: AsyncGitHubAPI +) -> Permission | None: + comment_author, owner, repo = EventInfo.from_event(event) -@{comment_author}, you need at least **{required_permission}** permission to use this command. + if not (comment_author and owner and repo): + return None -Your current permission level: **{user_permission}** -""" + return await aget_user_permission(gh, owner, repo, comment_author) async def acheck_mention_permission( event: sansio.Event, gh: AsyncGitHubAPI, required_permission: Permission ) -> PermissionCheck: - comment_author, owner, repo = EventInfo.from_event(event) - - if not (comment_author and owner and repo): - return PermissionCheck(has_permission=False, error_message=None) - - user_permission = await aget_user_permission(gh, owner, repo, comment_author) + user_permission = await aget_user_permission_from_event(event, gh) - if user_permission >= required_permission: - return PermissionCheck(has_permission=True, error_message=None) + if user_permission is None: + return PermissionCheck(has_permission=False) - return PermissionCheck( - has_permission=False, - error_message=PERMISSION_CHECK_ERROR_MESSAGE.format( - comment_author=comment_author, - required_permission=required_permission.name.lower(), - user_permission=user_permission.name.lower(), - ), - ) + return PermissionCheck(has_permission=user_permission >= required_permission) -def check_mention_permission( - event: sansio.Event, gh: SyncGitHubAPI, required_permission: Permission -) -> PermissionCheck: +def get_user_permission_from_event( + event: sansio.Event, gh: SyncGitHubAPI +) -> Permission | None: comment_author, owner, repo = EventInfo.from_event(event) if not (comment_author and owner and repo): - return PermissionCheck(has_permission=False, error_message=None) - - user_permission = get_user_permission(gh, owner, repo, comment_author) - - if user_permission >= required_permission: - return PermissionCheck(has_permission=True, error_message=None) - - return PermissionCheck( - has_permission=False, - error_message=PERMISSION_CHECK_ERROR_MESSAGE.format( - comment_author=comment_author, - required_permission=required_permission.name.lower(), - user_permission=user_permission.name.lower(), - ), - ) + return None + return get_user_permission(gh, owner, repo, comment_author) -def get_comment_post_url(event: sansio.Event) -> str | None: - if event.data.get("action") != "created": - return None - _, owner, repo = EventInfo.from_event(event) +def check_mention_permission( + event: sansio.Event, gh: SyncGitHubAPI, required_permission: Permission +) -> PermissionCheck: + user_permission = get_user_permission_from_event(event, gh) - if not (owner and repo): - return None + if user_permission is None: + return PermissionCheck(has_permission=False) - if "issue" in event.data: - issue_number = event.data["issue"]["number"] - return f"/repos/{owner}/{repo}/issues/{issue_number}/comments" - elif "pull_request" in event.data: - pr_number = event.data["pull_request"]["number"] - return f"/repos/{owner}/{repo}/issues/{pr_number}/comments" - elif "commit_sha" in event.data: - commit_sha = event.data["commit_sha"] - return f"/repos/{owner}/{repo}/commits/{commit_sha}/comments" - - return None + return PermissionCheck(has_permission=user_permission >= required_permission) diff --git a/src/django_github_app/routing.py b/src/django_github_app/routing.py index 1c082d6..3ca52b9 100644 --- a/src/django_github_app/routing.py +++ b/src/django_github_app/routing.py @@ -16,13 +16,13 @@ from ._typing import override from .github import AsyncGitHubAPI from .github import SyncGitHubAPI +from .mentions import MentionContext from .mentions import MentionScope from .mentions import check_event_for_mention -from .mentions import check_event_scope -from .permissions import Permission -from .permissions import acheck_mention_permission -from .permissions import check_mention_permission -from .permissions import get_comment_post_url +from .mentions import get_commands +from .mentions import get_event_scope +from .permissions import aget_user_permission_from_event +from .permissions import get_user_permission_from_event AsyncCallback = Callable[..., Awaitable[None]] SyncCallback = Callable[..., None] @@ -90,26 +90,15 @@ async def async_wrapper( if not check_event_for_mention(event, command, username): return - if not check_event_scope(event, scope): + event_scope = get_event_scope(event) + if scope is not None and event_scope != scope: return - # Check permissions if required - if permission is not None: - required_perm = Permission.from_string(permission) - permission_check = await acheck_mention_permission( - event, gh, required_perm - ) - - if not permission_check.has_permission: - # Post error comment if we have an error message - if permission_check.error_message: - comment_url = get_comment_post_url(event) - if comment_url: - await gh.post( - comment_url, - data={"body": permission_check.error_message}, - ) - return + kwargs["mention"] = MentionContext( + commands=get_commands(event, username), + user_permission=await aget_user_permission_from_event(event, gh), + scope=event_scope, + ) await func(event, gh, *args, **kwargs) # type: ignore[func-returns-value] @@ -123,26 +112,15 @@ def sync_wrapper( if not check_event_for_mention(event, command, username): return - if not check_event_scope(event, scope): + event_scope = get_event_scope(event) + if scope is not None and event_scope != scope: return - # Check permissions if required - if permission is not None: - required_perm = Permission.from_string(permission) - permission_check = check_mention_permission( - event, gh, required_perm - ) - - if not permission_check.has_permission: - # Post error comment if we have an error message - if permission_check.error_message: - comment_url = get_comment_post_url(event) - if comment_url: - gh.post( # type: ignore[unused-coroutine] - comment_url, - data={"body": permission_check.error_message}, - ) - return + kwargs["mention"] = MentionContext( + commands=get_commands(event, username), + user_permission=get_user_permission_from_event(event, gh), + scope=event_scope, + ) func(event, gh, *args, **kwargs) diff --git a/tests/test_mentions.py b/tests/test_mentions.py index 4a99f48..2e89b44 100644 --- a/tests/test_mentions.py +++ b/tests/test_mentions.py @@ -1,61 +1,75 @@ from __future__ import annotations +import pytest from gidgethub import sansio from django_github_app.mentions import MentionScope from django_github_app.mentions import check_event_for_mention -from django_github_app.mentions import check_event_scope +from django_github_app.mentions import get_commands +from django_github_app.mentions import get_event_scope from django_github_app.mentions import parse_mentions +@pytest.fixture +def create_comment_event(): + """Fixture to create comment events for testing.""" + + def _create(body: str) -> sansio.Event: + return sansio.Event( + {"comment": {"body": body}}, event="issue_comment", delivery_id="test" + ) + + return _create + + class TestParseMentions: - def test_simple_mention_with_command(self): - text = "@mybot help" - mentions = parse_mentions(text, "mybot") + def test_simple_mention_with_command(self, create_comment_event): + event = create_comment_event("@mybot help") + mentions = parse_mentions(event, "mybot") assert len(mentions) == 1 assert mentions[0].mention == "@mybot" assert mentions[0].command == "help" - def test_mention_without_command(self): - text = "@mybot" - mentions = parse_mentions(text, "mybot") + def test_mention_without_command(self, create_comment_event): + event = create_comment_event("@mybot") + mentions = parse_mentions(event, "mybot") assert len(mentions) == 1 assert mentions[0].mention == "@mybot" assert mentions[0].command is None - def test_case_insensitive_matching(self): - text = "@MyBot help" - mentions = parse_mentions(text, "mybot") + def test_case_insensitive_matching(self, create_comment_event): + event = create_comment_event("@MyBot help") + mentions = parse_mentions(event, "mybot") assert len(mentions) == 1 assert mentions[0].mention == "@MyBot" assert mentions[0].command == "help" - def test_command_case_normalization(self): - text = "@mybot HELP" - mentions = parse_mentions(text, "mybot") + def test_command_case_normalization(self, create_comment_event): + event = create_comment_event("@mybot HELP") + mentions = parse_mentions(event, "mybot") assert len(mentions) == 1 assert mentions[0].command == "help" - def test_multiple_mentions(self): - text = "@mybot help and then @mybot deploy" - mentions = parse_mentions(text, "mybot") + def test_multiple_mentions(self, create_comment_event): + event = create_comment_event("@mybot help and then @mybot deploy") + mentions = parse_mentions(event, "mybot") assert len(mentions) == 2 assert mentions[0].command == "help" assert mentions[1].command == "deploy" - def test_ignore_other_mentions(self): - text = "@otheruser help @mybot deploy @someone else" - mentions = parse_mentions(text, "mybot") + def test_ignore_other_mentions(self, create_comment_event): + event = create_comment_event("@otheruser help @mybot deploy @someone else") + mentions = parse_mentions(event, "mybot") assert len(mentions) == 1 assert mentions[0].command == "deploy" - def test_mention_in_code_block(self): + def test_mention_in_code_block(self, create_comment_event): text = """ Here's some text ``` @@ -63,104 +77,143 @@ def test_mention_in_code_block(self): ``` @mybot deploy """ - mentions = parse_mentions(text, "mybot") + event = create_comment_event(text) + mentions = parse_mentions(event, "mybot") assert len(mentions) == 1 assert mentions[0].command == "deploy" - def test_mention_in_inline_code(self): - text = "Use `@mybot help` for help, or just @mybot deploy" - mentions = parse_mentions(text, "mybot") + def test_mention_in_inline_code(self, create_comment_event): + event = create_comment_event( + "Use `@mybot help` for help, or just @mybot deploy" + ) + mentions = parse_mentions(event, "mybot") assert len(mentions) == 1 assert mentions[0].command == "deploy" - def test_mention_in_quote(self): + def test_mention_in_quote(self, create_comment_event): text = """ > @mybot help @mybot deploy """ - mentions = parse_mentions(text, "mybot") + event = create_comment_event(text) + mentions = parse_mentions(event, "mybot") assert len(mentions) == 1 assert mentions[0].command == "deploy" - def test_empty_text(self): - mentions = parse_mentions("", "mybot") + def test_empty_text(self, create_comment_event): + event = create_comment_event("") + mentions = parse_mentions(event, "mybot") assert mentions == [] - def test_none_text(self): - mentions = parse_mentions(None, "mybot") + def test_none_text(self, create_comment_event): + # Create an event with no comment body + event = sansio.Event({}, event="issue_comment", delivery_id="test") + mentions = parse_mentions(event, "mybot") assert mentions == [] - def test_mention_at_start_of_line(self): - text = "@mybot help" - mentions = parse_mentions(text, "mybot") + def test_mention_at_start_of_line(self, create_comment_event): + event = create_comment_event("@mybot help") + mentions = parse_mentions(event, "mybot") assert len(mentions) == 1 assert mentions[0].command == "help" - def test_mention_in_middle_of_text(self): - text = "Hey @mybot help me" - mentions = parse_mentions(text, "mybot") + def test_mention_in_middle_of_text(self, create_comment_event): + event = create_comment_event("Hey @mybot help me") + mentions = parse_mentions(event, "mybot") assert len(mentions) == 1 assert mentions[0].command == "help" - def test_mention_with_punctuation_after(self): - text = "@mybot help!" - mentions = parse_mentions(text, "mybot") + def test_mention_with_punctuation_after(self, create_comment_event): + event = create_comment_event("@mybot help!") + mentions = parse_mentions(event, "mybot") assert len(mentions) == 1 assert mentions[0].command == "help" - def test_hyphenated_username(self): - text = "@my-bot help" - mentions = parse_mentions(text, "my-bot") + def test_hyphenated_username(self, create_comment_event): + event = create_comment_event("@my-bot help") + mentions = parse_mentions(event, "my-bot") assert len(mentions) == 1 assert mentions[0].mention == "@my-bot" assert mentions[0].command == "help" - def test_underscore_username(self): - text = "@my_bot help" - mentions = parse_mentions(text, "my_bot") + def test_underscore_username(self, create_comment_event): + event = create_comment_event("@my_bot help") + mentions = parse_mentions(event, "my_bot") assert len(mentions) == 1 assert mentions[0].mention == "@my_bot" assert mentions[0].command == "help" - def test_no_space_after_mention(self): - text = "@mybot, please help" - mentions = parse_mentions(text, "mybot") + def test_no_space_after_mention(self, create_comment_event): + event = create_comment_event("@mybot, please help") + mentions = parse_mentions(event, "mybot") assert len(mentions) == 1 assert mentions[0].command is None - def test_multiple_spaces_before_command(self): - text = "@mybot help" - mentions = parse_mentions(text, "mybot") + def test_multiple_spaces_before_command(self, create_comment_event): + event = create_comment_event("@mybot help") + mentions = parse_mentions(event, "mybot") assert len(mentions) == 1 assert mentions[0].command == "help" - def test_hyphenated_command(self): - text = "@mybot async-test" - mentions = parse_mentions(text, "mybot") + def test_hyphenated_command(self, create_comment_event): + event = create_comment_event("@mybot async-test") + mentions = parse_mentions(event, "mybot") assert len(mentions) == 1 assert mentions[0].command == "async-test" - def test_special_character_command(self): - text = "@mybot ?" - mentions = parse_mentions(text, "mybot") + def test_special_character_command(self, create_comment_event): + event = create_comment_event("@mybot ?") + mentions = parse_mentions(event, "mybot") assert len(mentions) == 1 assert mentions[0].command == "?" +class TestGetCommands: + def test_single_command(self, create_comment_event): + event = create_comment_event("@bot deploy") + commands = get_commands(event, "bot") + assert commands == ["deploy"] + + def test_multiple_commands(self, create_comment_event): + event = create_comment_event("@bot help and @bot deploy and @bot test") + commands = get_commands(event, "bot") + assert commands == ["help", "deploy", "test"] + + def test_no_commands(self, create_comment_event): + event = create_comment_event("@bot") + commands = get_commands(event, "bot") + assert commands == [] + + def test_no_mentions(self, create_comment_event): + event = create_comment_event("Just a regular comment") + commands = get_commands(event, "bot") + assert commands == [] + + def test_mentions_of_other_users(self, create_comment_event): + event = create_comment_event("@otheruser deploy @bot help") + commands = get_commands(event, "bot") + assert commands == ["help"] + + def test_case_normalization(self, create_comment_event): + event = create_comment_event("@bot DEPLOY") + commands = get_commands(event, "bot") + assert commands == ["deploy"] + + class TestCheckMentionMatches: def test_match_with_command(self): event = sansio.Event( @@ -214,24 +267,26 @@ def test_multiple_mentions(self): assert check_event_for_mention(event, "test", "bot") is False -class TestCheckEventScope: - def test_no_scope_allows_all_events(self): - # When no scope is specified, all events should pass +class TestGetEventScope: + def test_get_event_scope_for_various_events(self): + # Issue comment on actual issue event1 = sansio.Event({"issue": {}}, event="issue_comment", delivery_id="1") - assert check_event_scope(event1, None) is True + assert get_event_scope(event1) == MentionScope.ISSUE + # PR review comment event2 = sansio.Event({}, event="pull_request_review_comment", delivery_id="2") - assert check_event_scope(event2, None) is True + assert get_event_scope(event2) == MentionScope.PR + # Commit comment event3 = sansio.Event({}, event="commit_comment", delivery_id="3") - assert check_event_scope(event3, None) is True + assert get_event_scope(event3) == MentionScope.COMMIT def test_issue_scope_on_issue_comment(self): # Issue comment on an actual issue (no pull_request field) issue_event = sansio.Event( {"issue": {"title": "Bug report"}}, event="issue_comment", delivery_id="1" ) - assert check_event_scope(issue_event, MentionScope.ISSUE) is True + assert get_event_scope(issue_event) == MentionScope.ISSUE # Issue comment on a pull request (has pull_request field) pr_event = sansio.Event( @@ -239,14 +294,14 @@ def test_issue_scope_on_issue_comment(self): event="issue_comment", delivery_id="2", ) - assert check_event_scope(pr_event, MentionScope.ISSUE) is False + assert get_event_scope(pr_event) == MentionScope.PR def test_pr_scope_on_issue_comment(self): # Issue comment on an actual issue (no pull_request field) issue_event = sansio.Event( {"issue": {"title": "Bug report"}}, event="issue_comment", delivery_id="1" ) - assert check_event_scope(issue_event, MentionScope.PR) is False + assert get_event_scope(issue_event) == MentionScope.ISSUE # Issue comment on a pull request (has pull_request field) pr_event = sansio.Event( @@ -254,42 +309,42 @@ def test_pr_scope_on_issue_comment(self): event="issue_comment", delivery_id="2", ) - assert check_event_scope(pr_event, MentionScope.PR) is True + assert get_event_scope(pr_event) == MentionScope.PR def test_pr_scope_allows_pr_specific_events(self): # PR scope should allow pull_request_review_comment event1 = sansio.Event({}, event="pull_request_review_comment", delivery_id="1") - assert check_event_scope(event1, MentionScope.PR) is True + assert get_event_scope(event1) == MentionScope.PR # PR scope should allow pull_request_review event2 = sansio.Event({}, event="pull_request_review", delivery_id="2") - assert check_event_scope(event2, MentionScope.PR) is True + assert get_event_scope(event2) == MentionScope.PR # PR scope should not allow commit_comment event3 = sansio.Event({}, event="commit_comment", delivery_id="3") - assert check_event_scope(event3, MentionScope.PR) is False + assert get_event_scope(event3) == MentionScope.COMMIT def test_commit_scope_allows_commit_comment_only(self): # Commit scope should allow commit_comment event1 = sansio.Event({}, event="commit_comment", delivery_id="1") - assert check_event_scope(event1, MentionScope.COMMIT) is True + assert get_event_scope(event1) == MentionScope.COMMIT # Commit scope should not allow issue_comment event2 = sansio.Event({"issue": {}}, event="issue_comment", delivery_id="2") - assert check_event_scope(event2, MentionScope.COMMIT) is False + assert get_event_scope(event2) == MentionScope.ISSUE # Commit scope should not allow PR events event3 = sansio.Event({}, event="pull_request_review_comment", delivery_id="3") - assert check_event_scope(event3, MentionScope.COMMIT) is False + assert get_event_scope(event3) == MentionScope.PR - def test_issue_scope_disallows_non_issue_events(self): - # Issue scope should not allow pull_request_review_comment + def test_different_event_types_have_correct_scope(self): + # pull_request_review_comment should be PR scope event1 = sansio.Event({}, event="pull_request_review_comment", delivery_id="1") - assert check_event_scope(event1, MentionScope.ISSUE) is False + assert get_event_scope(event1) == MentionScope.PR - # Issue scope should not allow commit_comment + # commit_comment should be COMMIT scope event2 = sansio.Event({}, event="commit_comment", delivery_id="2") - assert check_event_scope(event2, MentionScope.ISSUE) is False + assert get_event_scope(event2) == MentionScope.COMMIT def test_pull_request_field_none_treated_as_issue(self): # If pull_request field exists but is None, treat as issue @@ -298,11 +353,14 @@ def test_pull_request_field_none_treated_as_issue(self): event="issue_comment", delivery_id="1", ) - assert check_event_scope(event, MentionScope.ISSUE) is True - assert check_event_scope(event, MentionScope.PR) is False + assert get_event_scope(event) == MentionScope.ISSUE def test_missing_issue_data(self): - # If issue data is missing entirely, default behavior + # If issue data is missing entirely, defaults to ISSUE scope for issue_comment event = sansio.Event({}, event="issue_comment", delivery_id="1") - assert check_event_scope(event, MentionScope.ISSUE) is True - assert check_event_scope(event, MentionScope.PR) is False + assert get_event_scope(event) == MentionScope.ISSUE + + def test_unknown_event_returns_none(self): + # Unknown event types should return None + event = sansio.Event({}, event="unknown_event", delivery_id="1") + assert get_event_scope(event) is None diff --git a/tests/test_routing.py b/tests/test_routing.py index 6c99553..4291024 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -125,7 +125,7 @@ def test_router_memory_stress_test_legacy(self): class TestMentionDecorator: - def test_basic_mention_no_command(self, test_router): + def test_basic_mention_no_command(self, test_router, get_mock_github_api_sync): handler_called = False handler_args = None @@ -136,16 +136,22 @@ def handle_mention(event, *args, **kwargs): handler_args = (event, args, kwargs) event = sansio.Event( - {"action": "created", "comment": {"body": "@bot hello"}}, + { + "action": "created", + "comment": {"body": "@bot hello", "user": {"login": "testuser"}}, + "issue": {"number": 1}, + "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, + }, event="issue_comment", delivery_id="123", ) - test_router.dispatch(event, None) + mock_gh = get_mock_github_api_sync({"permission": "write"}) + test_router.dispatch(event, mock_gh) assert handler_called assert handler_args[0] == event - def test_mention_with_command(self, test_router): + def test_mention_with_command(self, test_router, get_mock_github_api_sync): handler_called = False @test_router.mention(command="help") @@ -155,15 +161,21 @@ def help_command(event, *args, **kwargs): return "help response" event = sansio.Event( - {"action": "created", "comment": {"body": "@bot help"}}, + { + "action": "created", + "comment": {"body": "@bot help", "user": {"login": "testuser"}}, + "issue": {"number": 2}, + "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, + }, event="issue_comment", delivery_id="123", ) - test_router.dispatch(event, None) + mock_gh = get_mock_github_api_sync({"permission": "write"}) + test_router.dispatch(event, mock_gh) assert handler_called - def test_mention_with_scope(self, test_router): + def test_mention_with_scope(self, test_router, get_mock_github_api_sync): pr_handler_called = False @test_router.mention(command="deploy", scope=MentionScope.PR) @@ -171,23 +183,34 @@ def deploy_command(event, *args, **kwargs): nonlocal pr_handler_called pr_handler_called = True + mock_gh = get_mock_github_api_sync({"permission": "write"}) + pr_event = sansio.Event( - {"action": "created", "comment": {"body": "@bot deploy"}}, + { + "action": "created", + "comment": {"body": "@bot deploy", "user": {"login": "testuser"}}, + "pull_request": {"number": 3}, + "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, + }, event="pull_request_review_comment", delivery_id="123", ) - test_router.dispatch(pr_event, None) + test_router.dispatch(pr_event, mock_gh) assert pr_handler_called issue_event = sansio.Event( - {"action": "created", "comment": {"body": "@bot deploy"}}, + { + "action": "created", + "comment": {"body": "@bot deploy", "user": {"login": "testuser"}}, + "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, + }, event="commit_comment", # This is NOT a PR event delivery_id="124", ) pr_handler_called = False # Reset - test_router.dispatch(issue_event, None) + test_router.dispatch(issue_event, mock_gh) assert not pr_handler_called @@ -217,7 +240,7 @@ def delete_command(event, *args, **kwargs): assert handler_called - def test_case_insensitive_command(self, test_router): + def test_case_insensitive_command(self, test_router, get_mock_github_api_sync): handler_called = False @test_router.mention(command="HELP") @@ -226,16 +249,24 @@ def help_command(event, *args, **kwargs): handler_called = True event = sansio.Event( - {"action": "created", "comment": {"body": "@bot help"}}, + { + "action": "created", + "comment": {"body": "@bot help", "user": {"login": "testuser"}}, + "issue": {"number": 4}, + "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, + }, event="issue_comment", delivery_id="123", ) - test_router.dispatch(event, None) + mock_gh = get_mock_github_api_sync({"permission": "write"}) + test_router.dispatch(event, mock_gh) assert handler_called @pytest.mark.parametrize("comment", ["@bot help", "@bot h", "@bot ?"]) - def test_multiple_decorators_on_same_function(self, comment, test_router): + def test_multiple_decorators_on_same_function( + self, comment, test_router, get_mock_github_api_sync + ): call_count = 0 @test_router.mention(command="help") @@ -247,15 +278,21 @@ def help_command(event, *args, **kwargs): return f"help called {call_count} times" event = sansio.Event( - {"action": "created", "comment": {"body": comment}}, + { + "action": "created", + "comment": {"body": comment, "user": {"login": "testuser"}}, + "issue": {"number": 5}, + "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, + }, event="issue_comment", delivery_id="123", ) - test_router.dispatch(event, None) + mock_gh = get_mock_github_api_sync({"permission": "write"}) + test_router.dispatch(event, mock_gh) assert call_count == 1 - def test_async_mention_handler(self, test_router): + def test_async_mention_handler(self, test_router, get_mock_github_api): handler_called = False @test_router.mention(command="async-test") @@ -265,16 +302,22 @@ async def async_handler(event, *args, **kwargs): return "async response" event = sansio.Event( - {"action": "created", "comment": {"body": "@bot async-test"}}, + { + "action": "created", + "comment": {"body": "@bot async-test", "user": {"login": "testuser"}}, + "issue": {"number": 1}, + "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, + }, event="issue_comment", delivery_id="123", ) - asyncio.run(test_router.adispatch(event, None)) + mock_gh = get_mock_github_api({"permission": "write"}) + asyncio.run(test_router.adispatch(event, mock_gh)) assert handler_called - def test_sync_mention_handler(self, test_router): + def test_sync_mention_handler(self, test_router, get_mock_github_api_sync): handler_called = False @test_router.mention(command="sync-test") @@ -284,15 +327,23 @@ def sync_handler(event, *args, **kwargs): return "sync response" event = sansio.Event( - {"action": "created", "comment": {"body": "@bot sync-test"}}, + { + "action": "created", + "comment": {"body": "@bot sync-test", "user": {"login": "testuser"}}, + "issue": {"number": 6}, + "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, + }, event="issue_comment", delivery_id="123", ) - test_router.dispatch(event, None) + mock_gh = get_mock_github_api_sync({"permission": "write"}) + test_router.dispatch(event, mock_gh) assert handler_called - def test_scope_validation_issue_comment_on_issue(self, test_router): + def test_scope_validation_issue_comment_on_issue( + self, test_router, get_mock_github_api_sync + ): """Test that ISSUE scope works for actual issues.""" handler_called = False @@ -306,16 +357,20 @@ def issue_handler(event, *args, **kwargs): { "action": "created", "issue": {"title": "Bug report", "number": 123}, - "comment": {"body": "@bot issue-only"}, + "comment": {"body": "@bot issue-only", "user": {"login": "testuser"}}, + "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, }, event="issue_comment", delivery_id="123", ) - test_router.dispatch(event, None) + mock_gh = get_mock_github_api_sync({"permission": "write"}) + test_router.dispatch(event, mock_gh) assert handler_called - def test_scope_validation_issue_comment_on_pr(self, test_router): + def test_scope_validation_issue_comment_on_pr( + self, test_router, get_mock_github_api_sync + ): """Test that ISSUE scope rejects PR comments.""" handler_called = False @@ -333,16 +388,20 @@ def issue_handler(event, *args, **kwargs): "number": 456, "pull_request": {"url": "https://api.github.com/..."}, }, - "comment": {"body": "@bot issue-only"}, + "comment": {"body": "@bot issue-only", "user": {"login": "testuser"}}, + "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, }, event="issue_comment", delivery_id="123", ) - test_router.dispatch(event, None) + mock_gh = get_mock_github_api_sync({"permission": "write"}) + test_router.dispatch(event, mock_gh) assert not handler_called - def test_scope_validation_pr_scope_on_pr(self, test_router): + def test_scope_validation_pr_scope_on_pr( + self, test_router, get_mock_github_api_sync + ): """Test that PR scope works for pull requests.""" handler_called = False @@ -360,16 +419,20 @@ def pr_handler(event, *args, **kwargs): "number": 456, "pull_request": {"url": "https://api.github.com/..."}, }, - "comment": {"body": "@bot pr-only"}, + "comment": {"body": "@bot pr-only", "user": {"login": "testuser"}}, + "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, }, event="issue_comment", delivery_id="123", ) - test_router.dispatch(event, None) + mock_gh = get_mock_github_api_sync({"permission": "write"}) + test_router.dispatch(event, mock_gh) assert handler_called - def test_scope_validation_pr_scope_on_issue(self, test_router): + def test_scope_validation_pr_scope_on_issue( + self, test_router, get_mock_github_api_sync + ): """Test that PR scope rejects issue comments.""" handler_called = False @@ -383,16 +446,18 @@ def pr_handler(event, *args, **kwargs): { "action": "created", "issue": {"title": "Bug report", "number": 123}, - "comment": {"body": "@bot pr-only"}, + "comment": {"body": "@bot pr-only", "user": {"login": "testuser"}}, + "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, }, event="issue_comment", delivery_id="123", ) - test_router.dispatch(event, None) + mock_gh = get_mock_github_api_sync({"permission": "write"}) + test_router.dispatch(event, mock_gh) assert not handler_called - def test_scope_validation_commit_scope(self, test_router): + def test_scope_validation_commit_scope(self, test_router, get_mock_github_api_sync): """Test that COMMIT scope works for commit comments.""" handler_called = False @@ -405,17 +470,19 @@ def commit_handler(event, *args, **kwargs): event = sansio.Event( { "action": "created", - "comment": {"body": "@bot commit-only"}, + "comment": {"body": "@bot commit-only", "user": {"login": "testuser"}}, "commit": {"sha": "abc123"}, + "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, }, event="commit_comment", delivery_id="123", ) - test_router.dispatch(event, None) + mock_gh = get_mock_github_api_sync({"permission": "write"}) + test_router.dispatch(event, mock_gh) assert handler_called - def test_scope_validation_no_scope(self, test_router): + def test_scope_validation_no_scope(self, test_router, get_mock_github_api_sync): """Test that no scope allows all comment types.""" call_count = 0 @@ -424,17 +491,20 @@ def all_handler(event, *args, **kwargs): nonlocal call_count call_count += 1 + mock_gh = get_mock_github_api_sync({"permission": "write"}) + # Test on issue event = sansio.Event( { "action": "created", "issue": {"title": "Issue", "number": 1}, - "comment": {"body": "@bot all-contexts"}, + "comment": {"body": "@bot all-contexts", "user": {"login": "testuser"}}, + "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, }, event="issue_comment", delivery_id="123", ) - test_router.dispatch(event, None) + test_router.dispatch(event, mock_gh) # Test on PR event = sansio.Event( @@ -445,36 +515,41 @@ def all_handler(event, *args, **kwargs): "number": 2, "pull_request": {"url": "..."}, }, - "comment": {"body": "@bot all-contexts"}, + "comment": {"body": "@bot all-contexts", "user": {"login": "testuser"}}, + "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, }, event="issue_comment", delivery_id="124", ) - test_router.dispatch(event, None) + test_router.dispatch(event, mock_gh) # Test on commit event = sansio.Event( { "action": "created", - "comment": {"body": "@bot all-contexts"}, + "comment": {"body": "@bot all-contexts", "user": {"login": "testuser"}}, "commit": {"sha": "abc123"}, + "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, }, event="commit_comment", delivery_id="125", ) - test_router.dispatch(event, None) + test_router.dispatch(event, mock_gh) assert call_count == 3 - def test_mention_permission_denied(self, test_router, get_mock_github_api_sync): - """Test that permission denial posts error comment.""" + def test_mention_enrichment_with_permission( + self, test_router, get_mock_github_api_sync + ): + """Test that mention decorator enriches kwargs with permission data.""" handler_called = False - posted_comment = None + captured_kwargs = {} @test_router.mention(command="admin-only", permission="admin") def admin_command(event, *args, **kwargs): - nonlocal handler_called + nonlocal handler_called, captured_kwargs handler_called = True + captured_kwargs = kwargs.copy() event = sansio.Event( { @@ -490,35 +565,28 @@ def admin_command(event, *args, **kwargs): # Mock the permission check to return write permission (less than admin) mock_gh = get_mock_github_api_sync({"permission": "write"}) - # Capture the posted comment - def capture_post(url, data=None, **kwargs): - nonlocal posted_comment - posted_comment = data.get("body") if data else None - - mock_gh.post = capture_post - test_router.dispatch(event, mock_gh) - # Handler should not be called - assert not handler_called - # Error comment should be posted - assert posted_comment is not None - assert "Permission Denied" in posted_comment - assert "admin" in posted_comment - assert "write" in posted_comment - assert "@testuser" in posted_comment - - def test_mention_permission_denied_no_permission( + # Handler SHOULD be called with enriched data + assert handler_called + assert "mention" in captured_kwargs + mention = captured_kwargs["mention"] + assert mention.commands == ["admin-only"] + assert mention.user_permission.name == "WRITE" + assert mention.scope.name == "ISSUE" + + def test_mention_enrichment_no_permission( self, test_router, get_mock_github_api_sync ): - """Test permission denial when user has no permission.""" + """Test enrichment when user has no permission.""" handler_called = False - posted_comment = None + captured_kwargs = {} @test_router.mention(command="write-required", permission="write") def write_command(event, *args, **kwargs): - nonlocal handler_called + nonlocal handler_called, captured_kwargs handler_called = True + captured_kwargs = kwargs.copy() event = sansio.Event( { @@ -541,36 +609,27 @@ def write_command(event, *args, **kwargs): {"private": True}, # Repo is private ] - # Capture the posted comment - def capture_post(url, data=None, **kwargs): - nonlocal posted_comment - posted_comment = data.get("body") if data else None - - mock_gh.post = capture_post - test_router.dispatch(event, mock_gh) - # Handler should not be called - assert not handler_called - # Error comment should be posted - assert posted_comment is not None - assert "Permission Denied" in posted_comment - assert "write" in posted_comment - assert "none" in posted_comment # User has no permission - assert "@stranger" in posted_comment + # Handler SHOULD be called with enriched data + assert handler_called + assert "mention" in captured_kwargs + mention = captured_kwargs["mention"] + assert mention.commands == ["write-required"] + assert mention.user_permission.name == "NONE" # User has no permission + assert mention.scope.name == "ISSUE" @pytest.mark.asyncio - async def test_async_mention_permission_denied( - self, test_router, get_mock_github_api - ): - """Test async permission denial posts error comment.""" + async def test_async_mention_enrichment(self, test_router, get_mock_github_api): + """Test async mention decorator enriches kwargs.""" handler_called = False - posted_comment = None + captured_kwargs = {} @test_router.mention(command="maintain-only", permission="maintain") async def maintain_command(event, *args, **kwargs): - nonlocal handler_called + nonlocal handler_called, captured_kwargs handler_called = True + captured_kwargs = kwargs.copy() event = sansio.Event( { @@ -589,20 +648,50 @@ async def maintain_command(event, *args, **kwargs): # Mock the permission check to return triage permission (less than maintain) mock_gh = get_mock_github_api({"permission": "triage"}) - # Capture the posted comment - async def capture_post(url, data=None, **kwargs): - nonlocal posted_comment - posted_comment = data.get("body") if data else None + await test_router.adispatch(event, mock_gh) - mock_gh.post = capture_post + # Handler SHOULD be called with enriched data + assert handler_called + assert "mention" in captured_kwargs + mention = captured_kwargs["mention"] + assert mention.commands == ["maintain-only"] + assert mention.user_permission.name == "TRIAGE" + assert mention.scope.name == "ISSUE" + + def test_mention_enrichment_pr_scope(self, test_router, get_mock_github_api_sync): + """Test that PR comments get correct scope enrichment.""" + handler_called = False + captured_kwargs = {} - await test_router.adispatch(event, mock_gh) + @test_router.mention(command="deploy") + def deploy_command(event, *args, **kwargs): + nonlocal handler_called, captured_kwargs + handler_called = True + captured_kwargs = kwargs.copy() - # Handler should not be called - assert not handler_called - # Error comment should be posted - assert posted_comment is not None - assert "Permission Denied" in posted_comment - assert "maintain" in posted_comment - assert "triage" in posted_comment - assert "@contributor" in posted_comment + # Issue comment on a PR (has pull_request field) + event = sansio.Event( + { + "action": "created", + "comment": {"body": "@bot deploy", "user": {"login": "dev"}}, + "issue": { + "number": 42, + "pull_request": { + "url": "https://api.github.com/repos/test/repo/pulls/42" + }, + }, + "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, + }, + event="issue_comment", + delivery_id="999", + ) + + mock_gh = get_mock_github_api_sync({"permission": "write"}) + test_router.dispatch(event, mock_gh) + + assert handler_called + assert "mention" in captured_kwargs + mention = captured_kwargs["mention"] + assert mention.commands == ["deploy"] + assert mention.user_permission.name == "WRITE" + assert mention.scope.name == "PR" # Should be PR, not ISSUE From 9ef1e05fe769921c344f4fc1c9aac60d23c09295 Mon Sep 17 00:00:00 2001 From: Josh Date: Wed, 18 Jun 2025 11:01:42 -0500 Subject: [PATCH 09/28] Refactor mention system to use explicit re.Pattern API --- src/django_github_app/mentions.py | 228 +++++++--- src/django_github_app/routing.py | 99 +++-- tests/settings.py | 1 + tests/test_mentions.py | 521 +++++++++++++++++------ tests/test_routing.py | 672 ++++++++++++++++++++++++++++-- 5 files changed, 1272 insertions(+), 249 deletions(-) diff --git a/src/django_github_app/mentions.py b/src/django_github_app/mentions.py index c6b569d..1985d3b 100644 --- a/src/django_github_app/mentions.py +++ b/src/django_github_app/mentions.py @@ -2,9 +2,12 @@ import re from dataclasses import dataclass +from datetime import datetime from enum import Enum from typing import NamedTuple +from django.conf import settings +from django.utils import timezone from gidgethub import sansio from .permissions import Permission @@ -46,77 +49,208 @@ def all_events(cls) -> list[EventAction]: ) +@dataclass +class Mention: + username: str + text: str + position: int + line_number: int + line_text: str + match: re.Match[str] | None = None + previous_mention: Mention | None = None + next_mention: Mention | None = None + + +@dataclass +class Comment: + body: str + author: str + created_at: datetime + url: str + mentions: list[Mention] + + @property + def line_count(self) -> int: + """Number of lines in the comment.""" + if not self.body: + return 0 + return len(self.body.splitlines()) + + @classmethod + def from_event(cls, event: sansio.Event) -> Comment: + match event.event: + case "issue_comment" | "pull_request_review_comment" | "commit_comment": + comment_data = event.data.get("comment") + case "pull_request_review": + comment_data = event.data.get("review") + case _: + comment_data = None + + if not comment_data: + raise ValueError(f"Cannot extract comment from event type: {event.event}") + + created_at_str = comment_data.get("created_at", "") + if created_at_str: + # GitHub timestamps are in ISO format: 2024-01-01T12:00:00Z + created_at_aware = datetime.fromisoformat( + created_at_str.replace("Z", "+00:00") + ) + if settings.USE_TZ: + created_at = created_at_aware + else: + created_at = timezone.make_naive( + created_at_aware, timezone.get_default_timezone() + ) + else: + created_at = timezone.now() + + author = comment_data.get("user", {}).get("login", "") + if not author and "sender" in event.data: + author = event.data.get("sender", {}).get("login", "") + + return cls( + body=comment_data.get("body", ""), + author=author, + created_at=created_at, + url=comment_data.get("html_url", ""), + mentions=[], + ) + + @dataclass class MentionContext: - commands: list[str] + comment: Comment + triggered_by: Mention user_permission: Permission | None scope: MentionScope | None -class MentionMatch(NamedTuple): - mention: str - command: str | None - - CODE_BLOCK_PATTERN = re.compile(r"```[\s\S]*?```", re.MULTILINE) INLINE_CODE_PATTERN = re.compile(r"`[^`]+`") QUOTE_PATTERN = re.compile(r"^\s*>.*$", re.MULTILINE) -def parse_mentions(event: sansio.Event, username: str) -> list[MentionMatch]: - text = event.data.get("comment", {}).get("body", "") +def get_event_scope(event: sansio.Event) -> MentionScope | None: + if event.event == "issue_comment": + issue = event.data.get("issue", {}) + is_pull_request = "pull_request" in issue and issue["pull_request"] is not None + return MentionScope.PR if is_pull_request else MentionScope.ISSUE - if not text: - return [] + for scope in MentionScope: + scope_events = scope.get_events() + if any(event_action.event == event.event for event_action in scope_events): + return scope - text = CODE_BLOCK_PATTERN.sub(lambda m: " " * len(m.group(0)), text) - text = INLINE_CODE_PATTERN.sub(lambda m: " " * len(m.group(0)), text) - text = QUOTE_PATTERN.sub(lambda m: " " * len(m.group(0)), text) + return None - username_pattern = re.compile( - rf"(?:^|(?<=\s))(@{re.escape(username)})(?:\s+([\w\-?]+))?(?=\s|$|[^\w\-])", - re.MULTILINE | re.IGNORECASE, - ) - mentions: list[MentionMatch] = [] - for match in username_pattern.finditer(text): - mention = match.group(1) # @username - command = match.group(2) # optional command - mentions.append( - MentionMatch(mention=mention, command=command.lower() if command else None) - ) +def check_pattern_match( + text: str, pattern: str | re.Pattern[str] | None +) -> re.Match[str] | None: + """Check if text matches the given pattern (string or regex). - return mentions + Returns Match object if pattern matches, None otherwise. + If pattern is None, returns a dummy match object. + """ + if pattern is None: + return re.match(r"(.*)", text, re.IGNORECASE | re.DOTALL) + # Check if it's a compiled regex pattern + if isinstance(pattern, re.Pattern): + # Use the pattern directly, preserving its flags + return pattern.match(text) -def get_commands(event: sansio.Event, username: str) -> list[str]: - mentions = parse_mentions(event, username) - return [m.command for m in mentions if m.command] + # For strings, do exact match (case-insensitive) + # Escape the string to treat it literally + escaped_pattern = re.escape(pattern) + return re.match(escaped_pattern, text, re.IGNORECASE) -def check_event_for_mention( - event: sansio.Event, command: str | None, username: str -) -> bool: - mentions = parse_mentions(event, username) +def parse_mentions_for_username( + event: sansio.Event, username_pattern: str | re.Pattern[str] | None = None +) -> list[Mention]: + body = event.data.get("comment", {}).get("body", "") - if not mentions: - return False + if not body: + return [] - if not command: - return True + # If no pattern specified, use bot username (TODO: get from settings) + if username_pattern is None: + username_pattern = "bot" # Placeholder + + # Handle regex patterns vs literal strings + if isinstance(username_pattern, re.Pattern): + # Use the pattern string directly, preserving any flags + username_regex = username_pattern.pattern + # Extract flags from the compiled pattern + flags = username_pattern.flags | re.MULTILINE | re.IGNORECASE + else: + # For strings, escape them to be treated literally + username_regex = re.escape(username_pattern) + flags = re.MULTILINE | re.IGNORECASE + + original_body = body + original_lines = original_body.splitlines() + + processed_text = CODE_BLOCK_PATTERN.sub(lambda m: " " * len(m.group(0)), body) + processed_text = INLINE_CODE_PATTERN.sub( + lambda m: " " * len(m.group(0)), processed_text + ) + processed_text = QUOTE_PATTERN.sub(lambda m: " " * len(m.group(0)), processed_text) - return any(mention.command == command.lower() for mention in mentions) + # Use \S+ to match non-whitespace characters for username + # Special handling for patterns that could match too broadly + if ".*" in username_regex: + # Replace .* with a more specific pattern that won't match spaces or @ + username_regex = username_regex.replace(".*", r"[^@\s]*") + mention_pattern = re.compile( + rf"(?:^|(?<=\s))@({username_regex})(?:\s|$|(?=[^\w\-]))", + flags, + ) -def get_event_scope(event: sansio.Event) -> MentionScope | None: - if event.event == "issue_comment": - issue = event.data.get("issue", {}) - is_pull_request = "pull_request" in issue and issue["pull_request"] is not None - return MentionScope.PR if is_pull_request else MentionScope.ISSUE + mentions: list[Mention] = [] - for scope in MentionScope: - scope_events = scope.get_events() - if any(event_action.event == event.event for event_action in scope_events): - return scope + for match in mention_pattern.finditer(processed_text): + position = match.start() # Position of @ + username = match.group(1) # Captured username - return None + text_before = original_body[:position] + line_number = text_before.count("\n") + 1 + + line_index = line_number - 1 + line_text = ( + original_lines[line_index] if line_index < len(original_lines) else "" + ) + + text_start = match.end() + + # Find next @mention to know where this text ends + next_match = mention_pattern.search(processed_text, match.end()) + if next_match: + text_end = next_match.start() + else: + text_end = len(original_body) + + text = original_body[text_start:text_end].strip() + + mention = Mention( + username=username, + text=text, + position=position, + line_number=line_number, + line_text=line_text, + match=None, + previous_mention=None, + next_mention=None, + ) + + mentions.append(mention) + + for i, mention in enumerate(mentions): + if i > 0: + mention.previous_mention = mentions[i - 1] + if i < len(mentions) - 1: + mention.next_mention = mentions[i + 1] + + return mentions diff --git a/src/django_github_app/routing.py b/src/django_github_app/routing.py index 3ca52b9..43ba47e 100644 --- a/src/django_github_app/routing.py +++ b/src/django_github_app/routing.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from asyncio import iscoroutinefunction from collections.abc import Awaitable from collections.abc import Callable @@ -16,11 +17,13 @@ from ._typing import override from .github import AsyncGitHubAPI from .github import SyncGitHubAPI +from .mentions import Comment from .mentions import MentionContext from .mentions import MentionScope -from .mentions import check_event_for_mention -from .mentions import get_commands +from .mentions import check_pattern_match from .mentions import get_event_scope +from .mentions import parse_mentions_for_username +from .permissions import Permission from .permissions import aget_user_permission_from_event from .permissions import get_user_permission_from_event @@ -31,9 +34,10 @@ class MentionHandlerBase(Protocol): - _mention_command: str | None - _mention_scope: MentionScope | None + _mention_pattern: str | re.Pattern[str] | None _mention_permission: str | None + _mention_scope: MentionScope | None + _mention_username: str | re.Pattern[str] | None class AsyncMentionHandler(MentionHandlerBase, Protocol): @@ -76,7 +80,9 @@ def decorator(func: CB) -> CB: def mention(self, **kwargs: Any) -> Callable[[CB], CB]: def decorator(func: CB) -> CB: - command = kwargs.pop("command", None) + # Support both old 'command' and new 'pattern' parameters + pattern = kwargs.pop("pattern", kwargs.pop("command", None)) + username = kwargs.pop("username", None) scope = kwargs.pop("scope", None) permission = kwargs.pop("permission", None) @@ -84,45 +90,75 @@ def decorator(func: CB) -> CB: async def async_wrapper( event: sansio.Event, gh: AsyncGitHubAPI, *args: Any, **kwargs: Any ) -> None: - # TODO: Get actual bot username from installation/app data - username = "bot" # Placeholder - - if not check_event_for_mention(event, command, username): - return - event_scope = get_event_scope(event) if scope is not None and event_scope != scope: return - kwargs["mention"] = MentionContext( - commands=get_commands(event, username), - user_permission=await aget_user_permission_from_event(event, gh), - scope=event_scope, - ) + mentions = parse_mentions_for_username(event, username) + if not mentions: + return + + user_permission = await aget_user_permission_from_event(event, gh) + if permission is not None: + required_perm = Permission[permission.upper()] + if user_permission is None or user_permission < required_perm: + return + + comment = Comment.from_event(event) + comment.mentions = mentions + + for mention in mentions: + if pattern is not None: + match = check_pattern_match(mention.text, pattern) + if not match: + continue + mention.match = match + + kwargs["mention"] = MentionContext( + comment=comment, + triggered_by=mention, + user_permission=user_permission, + scope=event_scope, + ) - await func(event, gh, *args, **kwargs) # type: ignore[func-returns-value] + await func(event, gh, *args, **kwargs) # type: ignore[func-returns-value] @wraps(func) def sync_wrapper( event: sansio.Event, gh: SyncGitHubAPI, *args: Any, **kwargs: Any ) -> None: - # TODO: Get actual bot username from installation/app data - username = "bot" # Placeholder - - if not check_event_for_mention(event, command, username): - return - event_scope = get_event_scope(event) if scope is not None and event_scope != scope: return - kwargs["mention"] = MentionContext( - commands=get_commands(event, username), - user_permission=get_user_permission_from_event(event, gh), - scope=event_scope, - ) + mentions = parse_mentions_for_username(event, username) + if not mentions: + return + + user_permission = get_user_permission_from_event(event, gh) + if permission is not None: + required_perm = Permission[permission.upper()] + if user_permission is None or user_permission < required_perm: + return - func(event, gh, *args, **kwargs) + comment = Comment.from_event(event) + comment.mentions = mentions + + for mention in mentions: + if pattern is not None: + match = check_pattern_match(mention.text, pattern) + if not match: + continue + mention.match = match + + kwargs["mention"] = MentionContext( + comment=comment, + triggered_by=mention, + user_permission=user_permission, + scope=event_scope, + ) + + func(event, gh, *args, **kwargs) wrapper: MentionHandler if iscoroutinefunction(func): @@ -130,9 +166,10 @@ def sync_wrapper( else: wrapper = cast(SyncMentionHandler, sync_wrapper) - wrapper._mention_command = command.lower() if command else None - wrapper._mention_scope = scope + wrapper._mention_pattern = pattern wrapper._mention_permission = permission + wrapper._mention_scope = scope + wrapper._mention_username = username events = scope.get_events() if scope else MentionScope.all_events() for event_action in events: diff --git a/tests/settings.py b/tests/settings.py index 3561b2a..6b0ce1d 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -20,4 +20,5 @@ "django.contrib.auth.hashers.MD5PasswordHasher", ], "SECRET_KEY": "not-a-secret", + "USE_TZ": True, } diff --git a/tests/test_mentions.py b/tests/test_mentions.py index 2e89b44..bacc60d 100644 --- a/tests/test_mentions.py +++ b/tests/test_mentions.py @@ -1,13 +1,15 @@ from __future__ import annotations +import re + import pytest +from django.utils import timezone from gidgethub import sansio +from django_github_app.mentions import Comment from django_github_app.mentions import MentionScope -from django_github_app.mentions import check_event_for_mention -from django_github_app.mentions import get_commands from django_github_app.mentions import get_event_scope -from django_github_app.mentions import parse_mentions +from django_github_app.mentions import parse_mentions_for_username @pytest.fixture @@ -25,49 +27,52 @@ def _create(body: str) -> sansio.Event: class TestParseMentions: def test_simple_mention_with_command(self, create_comment_event): event = create_comment_event("@mybot help") - mentions = parse_mentions(event, "mybot") + mentions = parse_mentions_for_username(event, "mybot") assert len(mentions) == 1 - assert mentions[0].mention == "@mybot" - assert mentions[0].command == "help" + assert mentions[0].username == "mybot" + assert mentions[0].text == "help" + assert mentions[0].position == 0 + assert mentions[0].line_number == 1 def test_mention_without_command(self, create_comment_event): event = create_comment_event("@mybot") - mentions = parse_mentions(event, "mybot") + mentions = parse_mentions_for_username(event, "mybot") assert len(mentions) == 1 - assert mentions[0].mention == "@mybot" - assert mentions[0].command is None + assert mentions[0].username == "mybot" + assert mentions[0].text == "" def test_case_insensitive_matching(self, create_comment_event): event = create_comment_event("@MyBot help") - mentions = parse_mentions(event, "mybot") + mentions = parse_mentions_for_username(event, "mybot") assert len(mentions) == 1 - assert mentions[0].mention == "@MyBot" - assert mentions[0].command == "help" + assert mentions[0].username == "MyBot" # Username is preserved as found + assert mentions[0].text == "help" def test_command_case_normalization(self, create_comment_event): event = create_comment_event("@mybot HELP") - mentions = parse_mentions(event, "mybot") + mentions = parse_mentions_for_username(event, "mybot") assert len(mentions) == 1 - assert mentions[0].command == "help" + # Command case is preserved in text, normalization happens elsewhere + assert mentions[0].text == "HELP" def test_multiple_mentions(self, create_comment_event): event = create_comment_event("@mybot help and then @mybot deploy") - mentions = parse_mentions(event, "mybot") + mentions = parse_mentions_for_username(event, "mybot") assert len(mentions) == 2 - assert mentions[0].command == "help" - assert mentions[1].command == "deploy" + assert mentions[0].text == "help and then" + assert mentions[1].text == "deploy" def test_ignore_other_mentions(self, create_comment_event): event = create_comment_event("@otheruser help @mybot deploy @someone else") - mentions = parse_mentions(event, "mybot") + mentions = parse_mentions_for_username(event, "mybot") assert len(mentions) == 1 - assert mentions[0].command == "deploy" + assert mentions[0].text == "deploy @someone else" def test_mention_in_code_block(self, create_comment_event): text = """ @@ -78,19 +83,19 @@ def test_mention_in_code_block(self, create_comment_event): @mybot deploy """ event = create_comment_event(text) - mentions = parse_mentions(event, "mybot") + mentions = parse_mentions_for_username(event, "mybot") assert len(mentions) == 1 - assert mentions[0].command == "deploy" + assert mentions[0].text == "deploy" def test_mention_in_inline_code(self, create_comment_event): event = create_comment_event( "Use `@mybot help` for help, or just @mybot deploy" ) - mentions = parse_mentions(event, "mybot") + mentions = parse_mentions_for_username(event, "mybot") assert len(mentions) == 1 - assert mentions[0].command == "deploy" + assert mentions[0].text == "deploy" def test_mention_in_quote(self, create_comment_event): text = """ @@ -98,173 +103,88 @@ def test_mention_in_quote(self, create_comment_event): @mybot deploy """ event = create_comment_event(text) - mentions = parse_mentions(event, "mybot") + mentions = parse_mentions_for_username(event, "mybot") assert len(mentions) == 1 - assert mentions[0].command == "deploy" + assert mentions[0].text == "deploy" def test_empty_text(self, create_comment_event): event = create_comment_event("") - mentions = parse_mentions(event, "mybot") + mentions = parse_mentions_for_username(event, "mybot") assert mentions == [] def test_none_text(self, create_comment_event): # Create an event with no comment body event = sansio.Event({}, event="issue_comment", delivery_id="test") - mentions = parse_mentions(event, "mybot") + mentions = parse_mentions_for_username(event, "mybot") assert mentions == [] def test_mention_at_start_of_line(self, create_comment_event): event = create_comment_event("@mybot help") - mentions = parse_mentions(event, "mybot") + mentions = parse_mentions_for_username(event, "mybot") assert len(mentions) == 1 - assert mentions[0].command == "help" + assert mentions[0].text == "help" def test_mention_in_middle_of_text(self, create_comment_event): event = create_comment_event("Hey @mybot help me") - mentions = parse_mentions(event, "mybot") + mentions = parse_mentions_for_username(event, "mybot") assert len(mentions) == 1 - assert mentions[0].command == "help" + assert mentions[0].text == "help me" def test_mention_with_punctuation_after(self, create_comment_event): event = create_comment_event("@mybot help!") - mentions = parse_mentions(event, "mybot") + mentions = parse_mentions_for_username(event, "mybot") assert len(mentions) == 1 - assert mentions[0].command == "help" + assert mentions[0].text == "help!" def test_hyphenated_username(self, create_comment_event): event = create_comment_event("@my-bot help") - mentions = parse_mentions(event, "my-bot") + mentions = parse_mentions_for_username(event, "my-bot") assert len(mentions) == 1 - assert mentions[0].mention == "@my-bot" - assert mentions[0].command == "help" + assert mentions[0].username == "my-bot" + assert mentions[0].text == "help" def test_underscore_username(self, create_comment_event): event = create_comment_event("@my_bot help") - mentions = parse_mentions(event, "my_bot") + mentions = parse_mentions_for_username(event, "my_bot") assert len(mentions) == 1 - assert mentions[0].mention == "@my_bot" - assert mentions[0].command == "help" + assert mentions[0].username == "my_bot" + assert mentions[0].text == "help" def test_no_space_after_mention(self, create_comment_event): event = create_comment_event("@mybot, please help") - mentions = parse_mentions(event, "mybot") + mentions = parse_mentions_for_username(event, "mybot") assert len(mentions) == 1 - assert mentions[0].command is None + assert mentions[0].text == ", please help" def test_multiple_spaces_before_command(self, create_comment_event): event = create_comment_event("@mybot help") - mentions = parse_mentions(event, "mybot") + mentions = parse_mentions_for_username(event, "mybot") assert len(mentions) == 1 - assert mentions[0].command == "help" + assert mentions[0].text == "help" # Whitespace is stripped def test_hyphenated_command(self, create_comment_event): event = create_comment_event("@mybot async-test") - mentions = parse_mentions(event, "mybot") + mentions = parse_mentions_for_username(event, "mybot") assert len(mentions) == 1 - assert mentions[0].command == "async-test" + assert mentions[0].text == "async-test" def test_special_character_command(self, create_comment_event): event = create_comment_event("@mybot ?") - mentions = parse_mentions(event, "mybot") + mentions = parse_mentions_for_username(event, "mybot") assert len(mentions) == 1 - assert mentions[0].command == "?" - - -class TestGetCommands: - def test_single_command(self, create_comment_event): - event = create_comment_event("@bot deploy") - commands = get_commands(event, "bot") - assert commands == ["deploy"] - - def test_multiple_commands(self, create_comment_event): - event = create_comment_event("@bot help and @bot deploy and @bot test") - commands = get_commands(event, "bot") - assert commands == ["help", "deploy", "test"] - - def test_no_commands(self, create_comment_event): - event = create_comment_event("@bot") - commands = get_commands(event, "bot") - assert commands == [] - - def test_no_mentions(self, create_comment_event): - event = create_comment_event("Just a regular comment") - commands = get_commands(event, "bot") - assert commands == [] - - def test_mentions_of_other_users(self, create_comment_event): - event = create_comment_event("@otheruser deploy @bot help") - commands = get_commands(event, "bot") - assert commands == ["help"] - - def test_case_normalization(self, create_comment_event): - event = create_comment_event("@bot DEPLOY") - commands = get_commands(event, "bot") - assert commands == ["deploy"] - - -class TestCheckMentionMatches: - def test_match_with_command(self): - event = sansio.Event( - {"comment": {"body": "@bot help"}}, event="issue_comment", delivery_id="123" - ) - - assert check_event_for_mention(event, "help", "bot") is True - assert check_event_for_mention(event, "deploy", "bot") is False - - def test_match_without_command(self): - event = sansio.Event( - {"comment": {"body": "@bot help"}}, event="issue_comment", delivery_id="123" - ) - - assert check_event_for_mention(event, None, "bot") is True - - event = sansio.Event( - {"comment": {"body": "no mention here"}}, - event="issue_comment", - delivery_id="124", - ) - - assert check_event_for_mention(event, None, "bot") is False - - def test_no_comment_body(self): - event = sansio.Event({}, event="issue_comment", delivery_id="123") - - assert check_event_for_mention(event, "help", "bot") is False - - event = sansio.Event({"comment": {}}, event="issue_comment", delivery_id="124") - - assert check_event_for_mention(event, "help", "bot") is False - - def test_case_insensitive_command_match(self): - event = sansio.Event( - {"comment": {"body": "@bot HELP"}}, event="issue_comment", delivery_id="123" - ) - - assert check_event_for_mention(event, "help", "bot") is True - assert check_event_for_mention(event, "HELP", "bot") is True - - def test_multiple_mentions(self): - event = sansio.Event( - {"comment": {"body": "@bot help @bot deploy"}}, - event="issue_comment", - delivery_id="123", - ) - - assert check_event_for_mention(event, "help", "bot") is True - assert check_event_for_mention(event, "deploy", "bot") is True - assert check_event_for_mention(event, "test", "bot") is False + assert mentions[0].text == "?" class TestGetEventScope: @@ -364,3 +284,340 @@ def test_unknown_event_returns_none(self): # Unknown event types should return None event = sansio.Event({}, event="unknown_event", delivery_id="1") assert get_event_scope(event) is None + + +class TestComment: + def test_from_event_issue_comment(self): + """Test Comment.from_event() with issue_comment event.""" + event = sansio.Event( + { + "comment": { + "body": "This is a test comment", + "user": {"login": "testuser"}, + "created_at": "2024-01-01T12:00:00Z", + "html_url": "https://github.com/test/repo/issues/1#issuecomment-123", + } + }, + event="issue_comment", + delivery_id="test-1", + ) + + comment = Comment.from_event(event) + + assert comment.body == "This is a test comment" + assert comment.author == "testuser" + assert comment.created_at.isoformat() == "2024-01-01T12:00:00+00:00" + assert comment.url == "https://github.com/test/repo/issues/1#issuecomment-123" + assert comment.mentions == [] + assert comment.line_count == 1 + + def test_from_event_pull_request_review_comment(self): + """Test Comment.from_event() with pull_request_review_comment event.""" + event = sansio.Event( + { + "comment": { + "body": "Line 1\nLine 2\nLine 3", + "user": {"login": "reviewer"}, + "created_at": "2024-02-15T14:30:00Z", + "html_url": "https://github.com/test/repo/pull/5#discussion_r123", + } + }, + event="pull_request_review_comment", + delivery_id="test-2", + ) + + comment = Comment.from_event(event) + + assert comment.body == "Line 1\nLine 2\nLine 3" + assert comment.author == "reviewer" + assert comment.url == "https://github.com/test/repo/pull/5#discussion_r123" + assert comment.line_count == 3 + + def test_from_event_pull_request_review(self): + """Test Comment.from_event() with pull_request_review event.""" + event = sansio.Event( + { + "review": { + "body": "LGTM!", + "user": {"login": "approver"}, + "created_at": "2024-03-10T09:15:00Z", + "html_url": "https://github.com/test/repo/pull/10#pullrequestreview-123", + } + }, + event="pull_request_review", + delivery_id="test-3", + ) + + comment = Comment.from_event(event) + + assert comment.body == "LGTM!" + assert comment.author == "approver" + assert ( + comment.url == "https://github.com/test/repo/pull/10#pullrequestreview-123" + ) + + def test_from_event_commit_comment(self): + """Test Comment.from_event() with commit_comment event.""" + event = sansio.Event( + { + "comment": { + "body": "Nice commit!", + "user": {"login": "commenter"}, + "created_at": "2024-04-20T16:45:00Z", + "html_url": "https://github.com/test/repo/commit/abc123#commitcomment-456", + } + }, + event="commit_comment", + delivery_id="test-4", + ) + + comment = Comment.from_event(event) + + assert comment.body == "Nice commit!" + assert comment.author == "commenter" + assert ( + comment.url + == "https://github.com/test/repo/commit/abc123#commitcomment-456" + ) + + def test_from_event_missing_fields(self): + """Test Comment.from_event() with missing optional fields.""" + event = sansio.Event( + { + "comment": { + "body": "Minimal comment", + # Missing user, created_at, html_url + }, + "sender": {"login": "fallback-user"}, + }, + event="issue_comment", + delivery_id="test-5", + ) + + comment = Comment.from_event(event) + + assert comment.body == "Minimal comment" + assert comment.author == "fallback-user" # Falls back to sender + assert comment.url == "" + # created_at should be roughly now + assert (timezone.now() - comment.created_at).total_seconds() < 5 + + def test_from_event_invalid_event_type(self): + """Test Comment.from_event() with unsupported event type.""" + event = sansio.Event( + {"some_data": "value"}, + event="push", + delivery_id="test-6", + ) + + with pytest.raises( + ValueError, match="Cannot extract comment from event type: push" + ): + Comment.from_event(event) + + def test_line_count_property(self): + """Test the line_count property with various comment bodies.""" + # Single line + comment = Comment( + body="Single line", + author="user", + created_at=timezone.now(), + url="", + mentions=[], + ) + assert comment.line_count == 1 + + # Multiple lines + comment.body = "Line 1\nLine 2\nLine 3" + assert comment.line_count == 3 + + # Empty lines count + comment.body = "Line 1\n\nLine 3" + assert comment.line_count == 3 + + # Empty body + comment.body = "" + assert comment.line_count == 0 + + def test_from_event_timezone_handling(self): + """Test timezone handling in created_at parsing.""" + event = sansio.Event( + { + "comment": { + "body": "Test", + "user": {"login": "user"}, + "created_at": "2024-01-01T12:00:00Z", + "html_url": "", + } + }, + event="issue_comment", + delivery_id="test-7", + ) + + comment = Comment.from_event(event) + + # Check that the datetime is timezone-aware (UTC) + assert comment.created_at.tzinfo is not None + assert comment.created_at.isoformat() == "2024-01-01T12:00:00+00:00" + + +class TestPatternMatching: + def test_check_pattern_match_none(self): + """Test check_pattern_match with None pattern.""" + from django_github_app.mentions import check_pattern_match + + match = check_pattern_match("any text", None) + assert match is not None + assert match.group(0) == "any text" + + def test_check_pattern_match_literal_string(self): + """Test check_pattern_match with literal string pattern.""" + from django_github_app.mentions import check_pattern_match + + # Matching case + match = check_pattern_match("deploy production", "deploy") + assert match is not None + assert match.group(0) == "deploy" + + # Case insensitive + match = check_pattern_match("DEPLOY production", "deploy") + assert match is not None + + # No match + match = check_pattern_match("help me", "deploy") + assert match is None + + # Must start with pattern + match = check_pattern_match("please deploy", "deploy") + assert match is None + + def test_check_pattern_match_regex(self): + """Test check_pattern_match with regex patterns.""" + from django_github_app.mentions import check_pattern_match + + # Simple regex + match = check_pattern_match("deploy prod", re.compile(r"deploy (prod|staging)")) + assert match is not None + assert match.group(0) == "deploy prod" + assert match.group(1) == "prod" + + # Named groups + match = check_pattern_match( + "deploy-prod", re.compile(r"deploy-(?Pprod|staging|dev)") + ) + assert match is not None + assert match.group("env") == "prod" + + # Question mark pattern + match = check_pattern_match("can you help?", re.compile(r".*\?$")) + assert match is not None + + # No match + match = check_pattern_match("deploy test", re.compile(r"deploy (prod|staging)")) + assert match is None + + def test_check_pattern_match_invalid_regex(self): + """Test check_pattern_match with invalid regex falls back to literal.""" + from django_github_app.mentions import check_pattern_match + + # Invalid regex should be treated as literal + match = check_pattern_match("test [invalid", "[invalid") + assert match is None # Doesn't start with [invalid + + match = check_pattern_match("[invalid regex", "[invalid") + assert match is not None # Starts with literal [invalid + + def test_check_pattern_match_flag_preservation(self): + """Test that regex flags are preserved when using compiled patterns.""" + from django_github_app.mentions import check_pattern_match + + # Case-sensitive pattern + pattern_cs = re.compile(r"DEPLOY", re.MULTILINE) + match = check_pattern_match("deploy", pattern_cs) + assert match is None # Should not match due to case sensitivity + + # Case-insensitive pattern + pattern_ci = re.compile(r"DEPLOY", re.IGNORECASE) + match = check_pattern_match("deploy", pattern_ci) + assert match is not None # Should match + + # Multiline pattern + pattern_ml = re.compile(r"^prod$", re.MULTILINE) + match = check_pattern_match("staging\nprod\ndev", pattern_ml) + assert match is None # Pattern expects exact match from start + + def test_parse_mentions_for_username_default(self): + """Test parse_mentions_for_username with default username.""" + from django_github_app.mentions import parse_mentions_for_username + + event = sansio.Event( + {"comment": {"body": "@bot help @otherbot test"}}, + event="issue_comment", + delivery_id="test", + ) + + mentions = parse_mentions_for_username(event, None) # Uses default "bot" + assert len(mentions) == 1 + assert mentions[0].username == "bot" + assert mentions[0].text == "help @otherbot test" + + def test_parse_mentions_for_username_specific(self): + """Test parse_mentions_for_username with specific username.""" + from django_github_app.mentions import parse_mentions_for_username + + event = sansio.Event( + {"comment": {"body": "@bot help @deploy-bot test @test-bot check"}}, + event="issue_comment", + delivery_id="test", + ) + + mentions = parse_mentions_for_username(event, "deploy-bot") + assert len(mentions) == 1 + assert mentions[0].username == "deploy-bot" + assert mentions[0].text == "test @test-bot check" + + def test_parse_mentions_for_username_regex(self): + """Test parse_mentions_for_username with regex pattern.""" + from django_github_app.mentions import parse_mentions_for_username + + event = sansio.Event( + { + "comment": { + "body": "@bot help @deploy-bot test @test-bot check @user ignore" + } + }, + event="issue_comment", + delivery_id="test", + ) + + # Match any username ending in -bot + mentions = parse_mentions_for_username(event, re.compile(r".*-bot")) + assert len(mentions) == 2 + assert mentions[0].username == "deploy-bot" + assert mentions[0].text == "test" + assert mentions[1].username == "test-bot" + assert mentions[1].text == "check @user ignore" + + # Verify mention linking + assert mentions[0].next_mention is mentions[1] + assert mentions[1].previous_mention is mentions[0] + + def test_parse_mentions_for_username_all(self): + """Test parse_mentions_for_username matching all mentions.""" + from django_github_app.mentions import parse_mentions_for_username + + event = sansio.Event( + {"comment": {"body": "@alice review @bob help @charlie test"}}, + event="issue_comment", + delivery_id="test", + ) + + # Match all mentions with .* + mentions = parse_mentions_for_username(event, re.compile(r".*")) + assert len(mentions) == 3 + assert mentions[0].username == "alice" + assert mentions[0].text == "review" + assert mentions[1].username == "bob" + assert mentions[1].text == "help" + assert mentions[2].username == "charlie" + assert mentions[2].text == "test" diff --git a/tests/test_routing.py b/tests/test_routing.py index 4291024..c5e9827 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import re import gidgethub import pytest @@ -263,34 +264,72 @@ def help_command(event, *args, **kwargs): assert handler_called - @pytest.mark.parametrize("comment", ["@bot help", "@bot h", "@bot ?"]) def test_multiple_decorators_on_same_function( - self, comment, test_router, get_mock_github_api_sync + self, test_router, get_mock_github_api_sync ): - call_count = 0 - - @test_router.mention(command="help") - @test_router.mention(command="h") - @test_router.mention(command="?") - def help_command(event, *args, **kwargs): - nonlocal call_count - call_count += 1 - return f"help called {call_count} times" - - event = sansio.Event( - { - "action": "created", - "comment": {"body": comment, "user": {"login": "testuser"}}, - "issue": {"number": 5}, - "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, - }, - event="issue_comment", - delivery_id="123", - ) - mock_gh = get_mock_github_api_sync({"permission": "write"}) - test_router.dispatch(event, mock_gh) - - assert call_count == 1 + """Test that multiple decorators on the same function work correctly.""" + # Create a fresh router for this test + from django_github_app.routing import GitHubRouter + + router = GitHubRouter() + + call_counts = {"help": 0, "h": 0, "?": 0} + + # Track which handler is being called + call_tracker = [] + + @router.mention(command="help") + def help_command_help(event, *args, **kwargs): + call_tracker.append("help decorator") + mention = kwargs.get("mention") + if mention and mention.triggered_by: + text = mention.triggered_by.text.strip() + if text in call_counts: + call_counts[text] += 1 + + @router.mention(command="h") + def help_command_h(event, *args, **kwargs): + call_tracker.append("h decorator") + mention = kwargs.get("mention") + if mention and mention.triggered_by: + text = mention.triggered_by.text.strip() + if text in call_counts: + call_counts[text] += 1 + + @router.mention(command="?") + def help_command_q(event, *args, **kwargs): + call_tracker.append("? decorator") + mention = kwargs.get("mention") + if mention and mention.triggered_by: + text = mention.triggered_by.text.strip() + if text in call_counts: + call_counts[text] += 1 + + # Test each command + for command_text in ["help", "h", "?"]: + event = sansio.Event( + { + "action": "created", + "comment": { + "body": f"@bot {command_text}", + "user": {"login": "testuser"}, + }, + "issue": {"number": 5}, + "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, + }, + event="issue_comment", + delivery_id=f"123-{command_text}", + ) + mock_gh = get_mock_github_api_sync({"permission": "write"}) + router.dispatch(event, mock_gh) + + # Check expected behavior: + # - "help" matches both "help" pattern and "h" pattern (since "help" starts with "h") + # - "h" matches only "h" pattern + # - "?" matches only "?" pattern + assert call_counts["help"] == 2 # Matched by both "help" and "h" patterns + assert call_counts["h"] == 1 # Matched only by "h" pattern + assert call_counts["?"] == 1 # Matched only by "?" pattern def test_async_mention_handler(self, test_router, get_mock_github_api): handler_called = False @@ -545,7 +584,7 @@ def test_mention_enrichment_with_permission( handler_called = False captured_kwargs = {} - @test_router.mention(command="admin-only", permission="admin") + @test_router.mention(command="admin-only") def admin_command(event, *args, **kwargs): nonlocal handler_called, captured_kwargs handler_called = True @@ -562,8 +601,8 @@ def admin_command(event, *args, **kwargs): delivery_id="123", ) - # Mock the permission check to return write permission (less than admin) - mock_gh = get_mock_github_api_sync({"permission": "write"}) + # Mock the permission check to return admin permission + mock_gh = get_mock_github_api_sync({"permission": "admin"}) test_router.dispatch(event, mock_gh) @@ -571,8 +610,10 @@ def admin_command(event, *args, **kwargs): assert handler_called assert "mention" in captured_kwargs mention = captured_kwargs["mention"] - assert mention.commands == ["admin-only"] - assert mention.user_permission.name == "WRITE" + # Check the new structure + assert mention.comment.body == "@bot admin-only" + assert mention.triggered_by.text == "admin-only" + assert mention.user_permission.name == "ADMIN" assert mention.scope.name == "ISSUE" def test_mention_enrichment_no_permission( @@ -582,7 +623,7 @@ def test_mention_enrichment_no_permission( handler_called = False captured_kwargs = {} - @test_router.mention(command="write-required", permission="write") + @test_router.mention(command="write-required") def write_command(event, *args, **kwargs): nonlocal handler_called, captured_kwargs handler_called = True @@ -615,7 +656,9 @@ def write_command(event, *args, **kwargs): assert handler_called assert "mention" in captured_kwargs mention = captured_kwargs["mention"] - assert mention.commands == ["write-required"] + # Check the new structure + assert mention.comment.body == "@bot write-required" + assert mention.triggered_by.text == "write-required" assert mention.user_permission.name == "NONE" # User has no permission assert mention.scope.name == "ISSUE" @@ -625,7 +668,7 @@ async def test_async_mention_enrichment(self, test_router, get_mock_github_api): handler_called = False captured_kwargs = {} - @test_router.mention(command="maintain-only", permission="maintain") + @test_router.mention(command="maintain-only") async def maintain_command(event, *args, **kwargs): nonlocal handler_called, captured_kwargs handler_called = True @@ -645,8 +688,8 @@ async def maintain_command(event, *args, **kwargs): delivery_id="789", ) - # Mock the permission check to return triage permission (less than maintain) - mock_gh = get_mock_github_api({"permission": "triage"}) + # Mock the permission check to return maintain permission + mock_gh = get_mock_github_api({"permission": "maintain"}) await test_router.adispatch(event, mock_gh) @@ -654,8 +697,10 @@ async def maintain_command(event, *args, **kwargs): assert handler_called assert "mention" in captured_kwargs mention = captured_kwargs["mention"] - assert mention.commands == ["maintain-only"] - assert mention.user_permission.name == "TRIAGE" + # Check the new structure + assert mention.comment.body == "@bot maintain-only" + assert mention.triggered_by.text == "maintain-only" + assert mention.user_permission.name == "MAINTAIN" assert mention.scope.name == "ISSUE" def test_mention_enrichment_pr_scope(self, test_router, get_mock_github_api_sync): @@ -692,6 +737,555 @@ def deploy_command(event, *args, **kwargs): assert handler_called assert "mention" in captured_kwargs mention = captured_kwargs["mention"] - assert mention.commands == ["deploy"] + # Check the new structure + assert mention.comment.body == "@bot deploy" + assert mention.triggered_by.text == "deploy" assert mention.user_permission.name == "WRITE" assert mention.scope.name == "PR" # Should be PR, not ISSUE + + +class TestUpdatedMentionContext: + """Test the updated MentionContext structure with comment and triggered_by fields.""" + + def test_mention_context_structure(self, test_router, get_mock_github_api_sync): + """Test that MentionContext has the new structure with comment and triggered_by.""" + handler_called = False + captured_mention = None + + @test_router.mention(command="test") + def test_handler(event, *args, **kwargs): + nonlocal handler_called, captured_mention + handler_called = True + captured_mention = kwargs.get("mention") + + event = sansio.Event( + { + "action": "created", + "comment": { + "body": "@bot test command", + "user": {"login": "testuser"}, + "created_at": "2024-01-01T12:00:00Z", + "html_url": "https://github.com/test/repo/issues/1#issuecomment-123", + }, + "issue": {"number": 1}, + "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, + }, + event="issue_comment", + delivery_id="123", + ) + + mock_gh = get_mock_github_api_sync({"permission": "write"}) + test_router.dispatch(event, mock_gh) + + assert handler_called + assert captured_mention is not None + + # Check Comment object + assert hasattr(captured_mention, "comment") + comment = captured_mention.comment + assert comment.body == "@bot test command" + assert comment.author == "testuser" + assert comment.url == "https://github.com/test/repo/issues/1#issuecomment-123" + assert len(comment.mentions) == 1 + + # Check triggered_by Mention object + assert hasattr(captured_mention, "triggered_by") + triggered = captured_mention.triggered_by + assert triggered.username == "bot" + assert triggered.text == "test command" + assert triggered.position == 0 + assert triggered.line_number == 1 + + # Check other fields still exist + assert captured_mention.user_permission.name == "WRITE" + assert captured_mention.scope.name == "ISSUE" + + def test_multiple_mentions_triggered_by( + self, test_router, get_mock_github_api_sync + ): + """Test that triggered_by is set correctly when multiple mentions exist.""" + handler_called = False + captured_mention = None + + @test_router.mention(command="deploy") + def deploy_handler(event, *args, **kwargs): + nonlocal handler_called, captured_mention + handler_called = True + captured_mention = kwargs.get("mention") + + event = sansio.Event( + { + "action": "created", + "comment": { + "body": "@bot help\n@bot deploy production", + "user": {"login": "testuser"}, + "created_at": "2024-01-01T12:00:00Z", + "html_url": "https://github.com/test/repo/issues/2#issuecomment-456", + }, + "issue": {"number": 2}, + "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, + }, + event="issue_comment", + delivery_id="456", + ) + + mock_gh = get_mock_github_api_sync({"permission": "write"}) + test_router.dispatch(event, mock_gh) + + assert handler_called + assert captured_mention is not None + + # Check that we have multiple mentions + assert len(captured_mention.comment.mentions) == 2 + + # Check triggered_by points to the "deploy" mention (second one) + assert captured_mention.triggered_by.text == "deploy production" + assert captured_mention.triggered_by.line_number == 2 + + # Verify mention linking + first_mention = captured_mention.comment.mentions[0] + second_mention = captured_mention.comment.mentions[1] + assert first_mention.next_mention is second_mention + assert second_mention.previous_mention is first_mention + + def test_mention_without_command(self, test_router, get_mock_github_api_sync): + """Test handler with no specific command uses first mention as triggered_by.""" + handler_called = False + captured_mention = None + + @test_router.mention() # No command specified + def general_handler(event, *args, **kwargs): + nonlocal handler_called, captured_mention + handler_called = True + captured_mention = kwargs.get("mention") + + event = sansio.Event( + { + "action": "created", + "comment": { + "body": "@bot can you help me?", + "user": {"login": "testuser"}, + "created_at": "2024-01-01T12:00:00Z", + "html_url": "https://github.com/test/repo/issues/3#issuecomment-789", + }, + "issue": {"number": 3}, + "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, + }, + event="issue_comment", + delivery_id="789", + ) + + mock_gh = get_mock_github_api_sync({"permission": "read"}) + test_router.dispatch(event, mock_gh) + + assert handler_called + assert captured_mention is not None + + # Should use first (and only) mention as triggered_by + assert captured_mention.triggered_by.text == "can you help me?" + assert captured_mention.triggered_by.username == "bot" + + @pytest.mark.asyncio + async def test_async_mention_context_structure( + self, test_router, get_mock_github_api + ): + """Test async handlers get the same updated MentionContext structure.""" + handler_called = False + captured_mention = None + + @test_router.mention(command="async-test") + async def async_handler(event, *args, **kwargs): + nonlocal handler_called, captured_mention + handler_called = True + captured_mention = kwargs.get("mention") + + event = sansio.Event( + { + "action": "created", + "comment": { + "body": "@bot async-test now", + "user": {"login": "asyncuser"}, + "created_at": "2024-01-01T13:00:00Z", + "html_url": "https://github.com/test/repo/issues/4#issuecomment-999", + }, + "issue": {"number": 4}, + "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, + }, + event="issue_comment", + delivery_id="999", + ) + + mock_gh = get_mock_github_api({"permission": "admin"}) + await test_router.adispatch(event, mock_gh) + + assert handler_called + assert captured_mention is not None + + # Verify structure is the same for async + assert captured_mention.comment.body == "@bot async-test now" + assert captured_mention.triggered_by.text == "async-test now" + assert captured_mention.user_permission.name == "ADMIN" + + +class TestFlexibleMentionTriggers: + """Test the extended mention decorator with username and pattern parameters.""" + + def test_pattern_parameter_string(self, test_router, get_mock_github_api_sync): + """Test pattern parameter with literal string matching.""" + handler_called = False + captured_mention = None + + @test_router.mention(pattern="deploy") + def deploy_handler(event, *args, **kwargs): + nonlocal handler_called, captured_mention + handler_called = True + captured_mention = kwargs.get("mention") + + # Should match + event = sansio.Event( + { + "action": "created", + "comment": { + "body": "@bot deploy production", + "user": {"login": "user"}, + }, + "issue": {"number": 1}, + "repository": {"owner": {"login": "owner"}, "name": "repo"}, + }, + event="issue_comment", + delivery_id="1", + ) + mock_gh = get_mock_github_api_sync({"permission": "write"}) + test_router.dispatch(event, mock_gh) + + assert handler_called + assert captured_mention.triggered_by.match is not None + assert captured_mention.triggered_by.match.group(0) == "deploy" + + # Should not match - pattern in middle + handler_called = False + event.data["comment"]["body"] = "@bot please deploy" + test_router.dispatch(event, mock_gh) + assert not handler_called + + def test_pattern_parameter_regex(self, test_router, get_mock_github_api_sync): + """Test pattern parameter with regex matching.""" + handler_called = False + captured_mention = None + + @test_router.mention(pattern=re.compile(r"deploy-(?Pprod|staging|dev)")) + def deploy_env_handler(event, *args, **kwargs): + nonlocal handler_called, captured_mention + handler_called = True + captured_mention = kwargs.get("mention") + + event = sansio.Event( + { + "action": "created", + "comment": {"body": "@bot deploy-staging", "user": {"login": "user"}}, + "issue": {"number": 1}, + "repository": {"owner": {"login": "owner"}, "name": "repo"}, + }, + event="issue_comment", + delivery_id="1", + ) + mock_gh = get_mock_github_api_sync({"permission": "write"}) + test_router.dispatch(event, mock_gh) + + assert handler_called + assert captured_mention.triggered_by.match is not None + assert captured_mention.triggered_by.match.group("env") == "staging" + + def test_username_parameter_exact(self, test_router, get_mock_github_api_sync): + """Test username parameter with exact matching.""" + handler_called = False + + @test_router.mention(username="deploy-bot") + def deploy_bot_handler(event, *args, **kwargs): + nonlocal handler_called + handler_called = True + + # Should match deploy-bot + event = sansio.Event( + { + "action": "created", + "comment": {"body": "@deploy-bot run tests", "user": {"login": "user"}}, + "issue": {"number": 1}, + "repository": {"owner": {"login": "owner"}, "name": "repo"}, + }, + event="issue_comment", + delivery_id="1", + ) + mock_gh = get_mock_github_api_sync({"permission": "write"}) + test_router.dispatch(event, mock_gh) + assert handler_called + + # Should not match bot + handler_called = False + event.data["comment"]["body"] = "@bot run tests" + test_router.dispatch(event, mock_gh) + assert not handler_called + + def test_username_parameter_regex(self, test_router, get_mock_github_api_sync): + """Test username parameter with regex matching.""" + handler_count = 0 + + @test_router.mention(username=re.compile(r".*-bot")) + def any_bot_handler(event, *args, **kwargs): + nonlocal handler_count + handler_count += 1 + + event = sansio.Event( + { + "action": "created", + "comment": { + "body": "@deploy-bot start @test-bot check @user help", + "user": {"login": "user"}, + }, + "issue": {"number": 1}, + "repository": {"owner": {"login": "owner"}, "name": "repo"}, + }, + event="issue_comment", + delivery_id="1", + ) + mock_gh = get_mock_github_api_sync({"permission": "write"}) + test_router.dispatch(event, mock_gh) + + # Should be called twice (deploy-bot and test-bot) + assert handler_count == 2 + + def test_username_all_mentions(self, test_router, get_mock_github_api_sync): + """Test monitoring all mentions with username=.*""" + mentions_seen = [] + + @test_router.mention(username=re.compile(r".*")) + def all_mentions_handler(event, *args, **kwargs): + mention = kwargs.get("mention") + mentions_seen.append(mention.triggered_by.username) + + event = sansio.Event( + { + "action": "created", + "comment": { + "body": "@alice review @bob deploy @charlie test", + "user": {"login": "user"}, + }, + "issue": {"number": 1}, + "repository": {"owner": {"login": "owner"}, "name": "repo"}, + }, + event="issue_comment", + delivery_id="1", + ) + mock_gh = get_mock_github_api_sync({"permission": "write"}) + test_router.dispatch(event, mock_gh) + + assert mentions_seen == ["alice", "bob", "charlie"] + + def test_combined_filters(self, test_router, get_mock_github_api_sync): + """Test combining username, pattern, permission, and scope filters.""" + calls = [] + + @test_router.mention( + username=re.compile(r".*-bot"), + pattern="deploy", + permission="write", + scope=MentionScope.PR, + ) + def restricted_deploy(event, *args, **kwargs): + calls.append(kwargs) + + # Create fresh events for each test to avoid any caching issues + def make_event(body): + return sansio.Event( + { + "action": "created", + "comment": {"body": body, "user": {"login": "user"}}, + "issue": {"number": 1, "pull_request": {"url": "..."}}, + "repository": {"owner": {"login": "owner"}, "name": "repo"}, + }, + event="issue_comment", + delivery_id="1", + ) + + # All conditions met + event1 = make_event("@deploy-bot deploy now") + mock_gh_write = get_mock_github_api_sync({}) + + # Mock the permission API call to return "write" permission + def mock_getitem_write(path): + if "collaborators" in path and "permission" in path: + return {"permission": "write"} + return {} + + mock_gh_write.getitem = mock_getitem_write + test_router.dispatch(event1, mock_gh_write) + assert len(calls) == 1 + + # Wrong username pattern + calls.clear() + event2 = make_event("@bot deploy now") + test_router.dispatch(event2, mock_gh_write) + assert len(calls) == 0 + + # Wrong pattern + calls.clear() + event3 = make_event("@deploy-bot help") + test_router.dispatch(event3, mock_gh_write) + assert len(calls) == 0 + + # Wrong scope (issue instead of PR) + calls.clear() + event4 = sansio.Event( + { + "action": "created", + "comment": { + "body": "@deploy-bot deploy now", + "user": {"login": "user"}, + }, + "issue": {"number": 1}, # No pull_request field + "repository": {"owner": {"login": "owner"}, "name": "repo"}, + }, + event="issue_comment", + delivery_id="1", + ) + test_router.dispatch(event4, mock_gh_write) + assert len(calls) == 0 + + # Insufficient permission + calls.clear() + event5 = make_event("@deploy-bot deploy now") + + # Clear the permission cache to ensure fresh permission check + from django_github_app.permissions import cache + + cache.clear() + + # Create a mock that returns read permission for the permission check + mock_gh_read = get_mock_github_api_sync({}) + + # Mock the permission API call to return "read" permission + def mock_getitem_read(path): + if "collaborators" in path and "permission" in path: + return {"permission": "read"} + return {} + + mock_gh_read.getitem = mock_getitem_read + + test_router.dispatch(event5, mock_gh_read) + assert len(calls) == 0 + + def test_multiple_decorators_different_patterns( + self, test_router, get_mock_github_api_sync + ): + """Test multiple decorators with different patterns on same function.""" + patterns_matched = [] + + @test_router.mention(pattern=re.compile(r"deploy")) + @test_router.mention(pattern=re.compile(r"ship")) + @test_router.mention(pattern=re.compile(r"release")) + def deploy_handler(event, *args, **kwargs): + mention = kwargs.get("mention") + patterns_matched.append(mention.triggered_by.text.split()[0]) + + event = sansio.Event( + { + "action": "created", + "comment": {"body": "@bot ship it", "user": {"login": "user"}}, + "issue": {"number": 1}, + "repository": {"owner": {"login": "owner"}, "name": "repo"}, + }, + event="issue_comment", + delivery_id="1", + ) + mock_gh = get_mock_github_api_sync({"permission": "write"}) + test_router.dispatch(event, mock_gh) + + assert patterns_matched == ["ship"] + + def test_question_pattern(self, test_router, get_mock_github_api_sync): + """Test natural language pattern matching for questions.""" + questions_received = [] + + @test_router.mention(pattern=re.compile(r".*\?$")) + def question_handler(event, *args, **kwargs): + mention = kwargs.get("mention") + questions_received.append(mention.triggered_by.text) + + event = sansio.Event( + { + "action": "created", + "comment": { + "body": "@bot what is the status?", + "user": {"login": "user"}, + }, + "issue": {"number": 1}, + "repository": {"owner": {"login": "owner"}, "name": "repo"}, + }, + event="issue_comment", + delivery_id="1", + ) + mock_gh = get_mock_github_api_sync({"permission": "write"}) + test_router.dispatch(event, mock_gh) + + assert questions_received == ["what is the status?"] + + # Non-question should not match + questions_received.clear() + event.data["comment"]["body"] = "@bot please help" + test_router.dispatch(event, mock_gh) + assert questions_received == [] + + def test_permission_filter_silently_skips( + self, test_router, get_mock_github_api_sync + ): + """Test that permission filter silently skips without error.""" + handler_called = False + + @test_router.mention(permission="admin") + def admin_only(event, *args, **kwargs): + nonlocal handler_called + handler_called = True + + event = sansio.Event( + { + "action": "created", + "comment": {"body": "@bot admin command", "user": {"login": "user"}}, + "issue": {"number": 1}, + "repository": {"owner": {"login": "owner"}, "name": "repo"}, + }, + event="issue_comment", + delivery_id="1", + ) + + # User has write permission (less than admin) + mock_gh = get_mock_github_api_sync({"permission": "write"}) + test_router.dispatch(event, mock_gh) + + # Should not be called, but no error + assert not handler_called + + def test_backward_compatibility_command( + self, test_router, get_mock_github_api_sync + ): + """Test that old 'command' parameter still works.""" + handler_called = False + + @test_router.mention(command="help") # Old style + def help_handler(event, *args, **kwargs): + nonlocal handler_called + handler_called = True + + event = sansio.Event( + { + "action": "created", + "comment": {"body": "@bot help me", "user": {"login": "user"}}, + "issue": {"number": 1}, + "repository": {"owner": {"login": "owner"}, "name": "repo"}, + }, + event="issue_comment", + delivery_id="1", + ) + mock_gh = get_mock_github_api_sync({"permission": "write"}) + test_router.dispatch(event, mock_gh) + + assert handler_called From baf7a56c67dd79c01585cf6751a7bc16304288a8 Mon Sep 17 00:00:00 2001 From: Josh Date: Wed, 18 Jun 2025 12:43:19 -0500 Subject: [PATCH 10/28] Simplify permission checking and remove optional return types --- src/django_github_app/mentions.py | 2 +- src/django_github_app/permissions.py | 114 +++++++++------------------ src/django_github_app/routing.py | 12 ++- tests/test_permissions.py | 85 ++++++++++++++++---- 4 files changed, 115 insertions(+), 98 deletions(-) diff --git a/src/django_github_app/mentions.py b/src/django_github_app/mentions.py index 1985d3b..929a2f9 100644 --- a/src/django_github_app/mentions.py +++ b/src/django_github_app/mentions.py @@ -121,7 +121,7 @@ def from_event(cls, event: sansio.Event) -> Comment: class MentionContext: comment: Comment triggered_by: Mention - user_permission: Permission | None + user_permission: Permission scope: MentionScope | None diff --git a/src/django_github_app/permissions.py b/src/django_github_app/permissions.py index 31735a1..6a12c10 100644 --- a/src/django_github_app/permissions.py +++ b/src/django_github_app/permissions.py @@ -48,10 +48,33 @@ class PermissionCacheKey(NamedTuple): username: str -async def aget_user_permission( - gh: AsyncGitHubAPI, owner: str, repo: str, username: str +class EventInfo(NamedTuple): + author: str | None + owner: str | None + repo: str | None + + @classmethod + def from_event(cls, event: sansio.Event) -> EventInfo: + comment = event.data.get("comment", {}) + repository = event.data.get("repository", {}) + + author = comment.get("user", {}).get("login") + owner = repository.get("owner", {}).get("login") + repo = repository.get("name") + + return cls(author=author, owner=owner, repo=repo) + + +async def aget_user_permission_from_event( + event: sansio.Event, gh: AsyncGitHubAPI ) -> Permission: - cache_key = PermissionCacheKey(owner, repo, username) + author, owner, repo = EventInfo.from_event(event) + + if not (author and owner and repo): + return Permission.NONE + + # Inline the logic from aget_user_permission + cache_key = PermissionCacheKey(owner, repo, author) if cache_key in cache: return cache[cache_key] @@ -61,7 +84,7 @@ async def aget_user_permission( try: # Check if user is a collaborator and get their permission data = await gh.getitem( - f"/repos/{owner}/{repo}/collaborators/{username}/permission" + f"/repos/{owner}/{repo}/collaborators/{author}/permission" ) permission_str = data.get("permission", "none") permission = Permission.from_string(permission_str) @@ -80,10 +103,16 @@ async def aget_user_permission( return permission -def get_user_permission( - gh: SyncGitHubAPI, owner: str, repo: str, username: str +def get_user_permission_from_event( + event: sansio.Event, gh: SyncGitHubAPI ) -> Permission: - cache_key = PermissionCacheKey(owner, repo, username) + author, owner, repo = EventInfo.from_event(event) + + if not (author and owner and repo): + return Permission.NONE + + # Inline the logic from get_user_permission + cache_key = PermissionCacheKey(owner, repo, author) if cache_key in cache: return cache[cache_key] @@ -92,7 +121,7 @@ def get_user_permission( try: # Check if user is a collaborator and get their permission - data = gh.getitem(f"/repos/{owner}/{repo}/collaborators/{username}/permission") + data = gh.getitem(f"/repos/{owner}/{repo}/collaborators/{author}/permission") permission_str = data.get("permission", "none") # type: ignore[attr-defined] permission = Permission.from_string(permission_str) except gidgethub.HTTPException as e: @@ -108,72 +137,3 @@ def get_user_permission( cache[cache_key] = permission return permission - - -class EventInfo(NamedTuple): - comment_author: str | None - owner: str | None - repo: str | None - - @classmethod - def from_event(cls, event: sansio.Event) -> EventInfo: - comment_author = None - owner = None - repo = None - - if "comment" in event.data: - comment_author = event.data["comment"]["user"]["login"] - - if "repository" in event.data: - owner = event.data["repository"]["owner"]["login"] - repo = event.data["repository"]["name"] - - return cls(comment_author=comment_author, owner=owner, repo=repo) - - -class PermissionCheck(NamedTuple): - has_permission: bool - - -async def aget_user_permission_from_event( - event: sansio.Event, gh: AsyncGitHubAPI -) -> Permission | None: - comment_author, owner, repo = EventInfo.from_event(event) - - if not (comment_author and owner and repo): - return None - - return await aget_user_permission(gh, owner, repo, comment_author) - - -async def acheck_mention_permission( - event: sansio.Event, gh: AsyncGitHubAPI, required_permission: Permission -) -> PermissionCheck: - user_permission = await aget_user_permission_from_event(event, gh) - - if user_permission is None: - return PermissionCheck(has_permission=False) - - return PermissionCheck(has_permission=user_permission >= required_permission) - - -def get_user_permission_from_event( - event: sansio.Event, gh: SyncGitHubAPI -) -> Permission | None: - comment_author, owner, repo = EventInfo.from_event(event) - - if not (comment_author and owner and repo): - return None - - return get_user_permission(gh, owner, repo, comment_author) - - -def check_mention_permission( - event: sansio.Event, gh: SyncGitHubAPI, required_permission: Permission -) -> PermissionCheck: - user_permission = get_user_permission_from_event(event, gh) - - if user_permission is None: - return PermissionCheck(has_permission=False) - - return PermissionCheck(has_permission=user_permission >= required_permission) diff --git a/src/django_github_app/routing.py b/src/django_github_app/routing.py index 43ba47e..497ee48 100644 --- a/src/django_github_app/routing.py +++ b/src/django_github_app/routing.py @@ -98,11 +98,13 @@ async def async_wrapper( if not mentions: return - user_permission = await aget_user_permission_from_event(event, gh) if permission is not None: + user_permission = await aget_user_permission_from_event(event, gh) required_perm = Permission[permission.upper()] - if user_permission is None or user_permission < required_perm: + if user_permission < required_perm: return + else: + user_permission = Permission.NONE comment = Comment.from_event(event) comment.mentions = mentions @@ -135,11 +137,13 @@ def sync_wrapper( if not mentions: return - user_permission = get_user_permission_from_event(event, gh) if permission is not None: + user_permission = get_user_permission_from_event(event, gh) required_perm = Permission[permission.upper()] - if user_permission is None or user_permission < required_perm: + if user_permission < required_perm: return + else: + user_permission = Permission.NONE comment = Comment.from_event(event) comment.mentions = mentions diff --git a/tests/test_permissions.py b/tests/test_permissions.py index 0db1af8..169badf 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -6,13 +6,14 @@ import gidgethub import pytest +from gidgethub import sansio from django_github_app.github import AsyncGitHubAPI from django_github_app.github import SyncGitHubAPI from django_github_app.permissions import Permission -from django_github_app.permissions import aget_user_permission +from django_github_app.permissions import aget_user_permission_from_event from django_github_app.permissions import cache -from django_github_app.permissions import get_user_permission +from django_github_app.permissions import get_user_permission_from_event @pytest.fixture(autouse=True) @@ -22,6 +23,18 @@ def clear_cache(): cache.clear() +def create_test_event(username: str, owner: str, repo: str) -> sansio.Event: + """Create a test event with comment author and repository info.""" + return sansio.Event( + { + "comment": {"user": {"login": username}}, + "repository": {"owner": {"login": owner}, "name": repo}, + }, + event="issue_comment", + delivery_id="test", + ) + + class TestPermission: def test_permission_ordering(self): assert Permission.NONE < Permission.READ @@ -64,8 +77,9 @@ class TestGetUserPermission: async def test_collaborator_with_admin_permission(self): gh = create_autospec(AsyncGitHubAPI, instance=True) gh.getitem = AsyncMock(return_value={"permission": "admin"}) + event = create_test_event("user", "owner", "repo") - permission = await aget_user_permission(gh, "owner", "repo", "user") + permission = await aget_user_permission_from_event(event, gh) assert permission == Permission.ADMIN gh.getitem.assert_called_once_with( @@ -75,8 +89,9 @@ async def test_collaborator_with_admin_permission(self): async def test_collaborator_with_write_permission(self): gh = create_autospec(AsyncGitHubAPI, instance=True) gh.getitem = AsyncMock(return_value={"permission": "write"}) + event = create_test_event("user", "owner", "repo") - permission = await aget_user_permission(gh, "owner", "repo", "user") + permission = await aget_user_permission_from_event(event, gh) assert permission == Permission.WRITE @@ -90,7 +105,8 @@ async def test_non_collaborator_public_repo(self): ] ) - permission = await aget_user_permission(gh, "owner", "repo", "user") + event = create_test_event("user", "owner", "repo") + permission = await aget_user_permission_from_event(event, gh) assert permission == Permission.READ assert gh.getitem.call_count == 2 @@ -106,8 +122,9 @@ async def test_non_collaborator_private_repo(self): {"private": True}, # Repo is private ] ) + event = create_test_event("user", "owner", "repo") - permission = await aget_user_permission(gh, "owner", "repo", "user") + permission = await aget_user_permission_from_event(event, gh) assert permission == Permission.NONE @@ -116,16 +133,18 @@ async def test_api_error_returns_none_permission(self): gh.getitem = AsyncMock( side_effect=gidgethub.HTTPException(500, "Server error", {}) ) + event = create_test_event("user", "owner", "repo") - permission = await aget_user_permission(gh, "owner", "repo", "user") + permission = await aget_user_permission_from_event(event, gh) assert permission == Permission.NONE async def test_missing_permission_field(self): gh = create_autospec(AsyncGitHubAPI, instance=True) gh.getitem = AsyncMock(return_value={}) # No permission field + event = create_test_event("user", "owner", "repo") - permission = await aget_user_permission(gh, "owner", "repo", "user") + permission = await aget_user_permission_from_event(event, gh) assert permission == Permission.NONE @@ -134,8 +153,9 @@ class TestGetUserPermissionSync: def test_collaborator_with_permission(self): gh = create_autospec(SyncGitHubAPI, instance=True) gh.getitem = Mock(return_value={"permission": "maintain"}) + event = create_test_event("user", "owner", "repo") - permission = get_user_permission(gh, "owner", "repo", "user") + permission = get_user_permission_from_event(event, gh) assert permission == Permission.MAINTAIN gh.getitem.assert_called_once_with( @@ -151,8 +171,9 @@ def test_non_collaborator_public_repo(self): {"private": False}, # Repo is public ] ) + event = create_test_event("user", "owner", "repo") - permission = get_user_permission(gh, "owner", "repo", "user") + permission = get_user_permission_from_event(event, gh) assert permission == Permission.READ @@ -162,14 +183,15 @@ class TestPermissionCaching: async def test_cache_hit(self): gh = create_autospec(AsyncGitHubAPI, instance=True) gh.getitem = AsyncMock(return_value={"permission": "write"}) + event = create_test_event("user", "owner", "repo") # First call should hit the API - perm1 = await aget_user_permission(gh, "owner", "repo", "user") + perm1 = await aget_user_permission_from_event(event, gh) assert perm1 == Permission.WRITE assert gh.getitem.call_count == 1 # Second call should use cache - perm2 = await aget_user_permission(gh, "owner", "repo", "user") + perm2 = await aget_user_permission_from_event(event, gh) assert perm2 == Permission.WRITE assert gh.getitem.call_count == 1 # No additional API call @@ -182,9 +204,11 @@ async def test_cache_different_users(self): {"permission": "admin"}, ] ) + event1 = create_test_event("user1", "owner", "repo") + event2 = create_test_event("user2", "owner", "repo") - perm1 = await aget_user_permission(gh, "owner", "repo", "user1") - perm2 = await aget_user_permission(gh, "owner", "repo", "user2") + perm1 = await aget_user_permission_from_event(event1, gh) + perm2 = await aget_user_permission_from_event(event2, gh) assert perm1 == Permission.WRITE assert perm2 == Permission.ADMIN @@ -194,13 +218,42 @@ def test_sync_cache_hit(self): """Test that sync version uses cache.""" gh = create_autospec(SyncGitHubAPI, instance=True) gh.getitem = Mock(return_value={"permission": "read"}) + event = create_test_event("user", "owner", "repo") # First call should hit the API - perm1 = get_user_permission(gh, "owner", "repo", "user") + perm1 = get_user_permission_from_event(event, gh) assert perm1 == Permission.READ assert gh.getitem.call_count == 1 # Second call should use cache - perm2 = get_user_permission(gh, "owner", "repo", "user") + perm2 = get_user_permission_from_event(event, gh) assert perm2 == Permission.READ assert gh.getitem.call_count == 1 # No additional API call + + +class TestPermissionFromEvent: + @pytest.mark.asyncio + async def test_missing_comment_data(self): + """Test when event has no comment data.""" + gh = create_autospec(AsyncGitHubAPI, instance=True) + event = sansio.Event({}, event="issue_comment", delivery_id="test") + + permission = await aget_user_permission_from_event(event, gh) + + assert permission == Permission.NONE + assert gh.getitem.called is False + + @pytest.mark.asyncio + async def test_missing_repository_data(self): + """Test when event has no repository data.""" + gh = create_autospec(AsyncGitHubAPI, instance=True) + event = sansio.Event( + {"comment": {"user": {"login": "user"}}}, + event="issue_comment", + delivery_id="test", + ) + + permission = await aget_user_permission_from_event(event, gh) + + assert permission == Permission.NONE + assert gh.getitem.called is False From ac889002de56b90d7621bfdd582198daba7ec6c5 Mon Sep 17 00:00:00 2001 From: Josh Date: Wed, 18 Jun 2025 18:46:11 -0500 Subject: [PATCH 11/28] Strip mention system down to core functionality --- src/django_github_app/mentions.py | 8 +- src/django_github_app/permissions.py | 139 ---------- src/django_github_app/routing.py | 26 +- tests/conftest.py | 6 +- tests/test_permissions.py | 259 ------------------ tests/test_routing.py | 388 +++++---------------------- 6 files changed, 77 insertions(+), 749 deletions(-) delete mode 100644 src/django_github_app/permissions.py delete mode 100644 tests/test_permissions.py diff --git a/src/django_github_app/mentions.py b/src/django_github_app/mentions.py index 929a2f9..5fa53a2 100644 --- a/src/django_github_app/mentions.py +++ b/src/django_github_app/mentions.py @@ -10,8 +10,6 @@ from django.utils import timezone from gidgethub import sansio -from .permissions import Permission - class EventAction(NamedTuple): event: str @@ -121,7 +119,6 @@ def from_event(cls, event: sansio.Event) -> Comment: class MentionContext: comment: Comment triggered_by: Mention - user_permission: Permission scope: MentionScope | None @@ -169,7 +166,10 @@ def check_pattern_match( def parse_mentions_for_username( event: sansio.Event, username_pattern: str | re.Pattern[str] | None = None ) -> list[Mention]: - body = event.data.get("comment", {}).get("body", "") + comment = event.data.get("comment", {}) + if comment is None: + comment = {} + body = comment.get("body", "") if not body: return [] diff --git a/src/django_github_app/permissions.py b/src/django_github_app/permissions.py deleted file mode 100644 index 6a12c10..0000000 --- a/src/django_github_app/permissions.py +++ /dev/null @@ -1,139 +0,0 @@ -from __future__ import annotations - -from enum import Enum -from typing import NamedTuple - -import cachetools -import gidgethub -from gidgethub import sansio - -from django_github_app.github import AsyncGitHubAPI -from django_github_app.github import SyncGitHubAPI - - -class Permission(int, Enum): - NONE = 0 - READ = 1 - TRIAGE = 2 - WRITE = 3 - MAINTAIN = 4 - ADMIN = 5 - - @classmethod - def from_string(cls, permission: str) -> Permission: - permission_map = { - "none": cls.NONE, - "read": cls.READ, - "triage": cls.TRIAGE, - "write": cls.WRITE, - "maintain": cls.MAINTAIN, - "admin": cls.ADMIN, - } - - normalized = permission.lower().strip() - if normalized not in permission_map: - raise ValueError(f"Unknown permission level: {permission}") - - return permission_map[normalized] - - -cache: cachetools.LRUCache[PermissionCacheKey, Permission] = cachetools.LRUCache( - maxsize=128 -) - - -class PermissionCacheKey(NamedTuple): - owner: str - repo: str - username: str - - -class EventInfo(NamedTuple): - author: str | None - owner: str | None - repo: str | None - - @classmethod - def from_event(cls, event: sansio.Event) -> EventInfo: - comment = event.data.get("comment", {}) - repository = event.data.get("repository", {}) - - author = comment.get("user", {}).get("login") - owner = repository.get("owner", {}).get("login") - repo = repository.get("name") - - return cls(author=author, owner=owner, repo=repo) - - -async def aget_user_permission_from_event( - event: sansio.Event, gh: AsyncGitHubAPI -) -> Permission: - author, owner, repo = EventInfo.from_event(event) - - if not (author and owner and repo): - return Permission.NONE - - # Inline the logic from aget_user_permission - cache_key = PermissionCacheKey(owner, repo, author) - - if cache_key in cache: - return cache[cache_key] - - permission = Permission.NONE - - try: - # Check if user is a collaborator and get their permission - data = await gh.getitem( - f"/repos/{owner}/{repo}/collaborators/{author}/permission" - ) - permission_str = data.get("permission", "none") - permission = Permission.from_string(permission_str) - except gidgethub.HTTPException as e: - if e.status_code == 404: - # User is not a collaborator, they have read permission if repo is public - # Check if repo is public - try: - repo_data = await gh.getitem(f"/repos/{owner}/{repo}") - if not repo_data.get("private", True): - permission = Permission.READ - except gidgethub.HTTPException: - pass - - cache[cache_key] = permission - return permission - - -def get_user_permission_from_event( - event: sansio.Event, gh: SyncGitHubAPI -) -> Permission: - author, owner, repo = EventInfo.from_event(event) - - if not (author and owner and repo): - return Permission.NONE - - # Inline the logic from get_user_permission - cache_key = PermissionCacheKey(owner, repo, author) - - if cache_key in cache: - return cache[cache_key] - - permission = Permission.NONE - - try: - # Check if user is a collaborator and get their permission - data = gh.getitem(f"/repos/{owner}/{repo}/collaborators/{author}/permission") - permission_str = data.get("permission", "none") # type: ignore[attr-defined] - permission = Permission.from_string(permission_str) - except gidgethub.HTTPException as e: - if e.status_code == 404: - # User is not a collaborator, they have read permission if repo is public - # Check if repo is public - try: - repo_data = gh.getitem(f"/repos/{owner}/{repo}") - if not repo_data.get("private", True): # type: ignore[attr-defined] - permission = Permission.READ - except gidgethub.HTTPException: - pass - - cache[cache_key] = permission - return permission diff --git a/src/django_github_app/routing.py b/src/django_github_app/routing.py index 497ee48..36982f7 100644 --- a/src/django_github_app/routing.py +++ b/src/django_github_app/routing.py @@ -23,9 +23,6 @@ from .mentions import check_pattern_match from .mentions import get_event_scope from .mentions import parse_mentions_for_username -from .permissions import Permission -from .permissions import aget_user_permission_from_event -from .permissions import get_user_permission_from_event AsyncCallback = Callable[..., Awaitable[None]] SyncCallback = Callable[..., None] @@ -80,11 +77,9 @@ def decorator(func: CB) -> CB: def mention(self, **kwargs: Any) -> Callable[[CB], CB]: def decorator(func: CB) -> CB: - # Support both old 'command' and new 'pattern' parameters - pattern = kwargs.pop("pattern", kwargs.pop("command", None)) + pattern = kwargs.pop("pattern", None) username = kwargs.pop("username", None) scope = kwargs.pop("scope", None) - permission = kwargs.pop("permission", None) @wraps(func) async def async_wrapper( @@ -98,14 +93,6 @@ async def async_wrapper( if not mentions: return - if permission is not None: - user_permission = await aget_user_permission_from_event(event, gh) - required_perm = Permission[permission.upper()] - if user_permission < required_perm: - return - else: - user_permission = Permission.NONE - comment = Comment.from_event(event) comment.mentions = mentions @@ -119,7 +106,6 @@ async def async_wrapper( kwargs["mention"] = MentionContext( comment=comment, triggered_by=mention, - user_permission=user_permission, scope=event_scope, ) @@ -137,14 +123,6 @@ def sync_wrapper( if not mentions: return - if permission is not None: - user_permission = get_user_permission_from_event(event, gh) - required_perm = Permission[permission.upper()] - if user_permission < required_perm: - return - else: - user_permission = Permission.NONE - comment = Comment.from_event(event) comment.mentions = mentions @@ -158,7 +136,6 @@ def sync_wrapper( kwargs["mention"] = MentionContext( comment=comment, triggered_by=mention, - user_permission=user_permission, scope=event_scope, ) @@ -171,7 +148,6 @@ def sync_wrapper( wrapper = cast(SyncMentionHandler, sync_wrapper) wrapper._mention_pattern = pattern - wrapper._mention_permission = permission wrapper._mention_scope = scope wrapper._mention_username = username diff --git a/tests/conftest.py b/tests/conftest.py index 234e119..5d1d3f2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -130,7 +130,7 @@ def repository_id(): @pytest.fixture def get_mock_github_api(): - def _get_mock_github_api(return_data): + def _get_mock_github_api(return_data, installation_id=12345): mock_api = AsyncMock(spec=AsyncGitHubAPI) async def mock_getitem(*args, **kwargs): @@ -144,6 +144,7 @@ async def mock_getiter(*args, **kwargs): mock_api.getiter = mock_getiter mock_api.__aenter__.return_value = mock_api mock_api.__aexit__.return_value = None + mock_api.installation_id = installation_id return mock_api @@ -152,7 +153,7 @@ async def mock_getiter(*args, **kwargs): @pytest.fixture def get_mock_github_api_sync(): - def _get_mock_github_api_sync(return_data): + def _get_mock_github_api_sync(return_data, installation_id=12345): from django_github_app.github import SyncGitHubAPI mock_api = MagicMock(spec=SyncGitHubAPI) @@ -169,6 +170,7 @@ def mock_post(*args, **kwargs): mock_api.getitem = mock_getitem mock_api.getiter = mock_getiter mock_api.post = mock_post + mock_api.installation_id = installation_id return mock_api diff --git a/tests/test_permissions.py b/tests/test_permissions.py deleted file mode 100644 index 169badf..0000000 --- a/tests/test_permissions.py +++ /dev/null @@ -1,259 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock -from unittest.mock import Mock -from unittest.mock import create_autospec - -import gidgethub -import pytest -from gidgethub import sansio - -from django_github_app.github import AsyncGitHubAPI -from django_github_app.github import SyncGitHubAPI -from django_github_app.permissions import Permission -from django_github_app.permissions import aget_user_permission_from_event -from django_github_app.permissions import cache -from django_github_app.permissions import get_user_permission_from_event - - -@pytest.fixture(autouse=True) -def clear_cache(): - cache.clear() - yield - cache.clear() - - -def create_test_event(username: str, owner: str, repo: str) -> sansio.Event: - """Create a test event with comment author and repository info.""" - return sansio.Event( - { - "comment": {"user": {"login": username}}, - "repository": {"owner": {"login": owner}, "name": repo}, - }, - event="issue_comment", - delivery_id="test", - ) - - -class TestPermission: - def test_permission_ordering(self): - assert Permission.NONE < Permission.READ - assert Permission.READ < Permission.TRIAGE - assert Permission.TRIAGE < Permission.WRITE - assert Permission.WRITE < Permission.MAINTAIN - assert Permission.MAINTAIN < Permission.ADMIN - - assert Permission.ADMIN > Permission.WRITE - assert Permission.WRITE >= Permission.WRITE - assert Permission.READ <= Permission.TRIAGE - - @pytest.mark.parametrize( - "permission_str,expected", - [ - ("read", Permission.READ), - ("Read", Permission.READ), - ("READ", Permission.READ), - (" read ", Permission.READ), - ("triage", Permission.TRIAGE), - ("write", Permission.WRITE), - ("maintain", Permission.MAINTAIN), - ("admin", Permission.ADMIN), - ("none", Permission.NONE), - ], - ) - def test_from_string(self, permission_str, expected): - assert Permission.from_string(permission_str) == expected - - def test_from_string_invalid(self): - with pytest.raises(ValueError, match="Unknown permission level: invalid"): - Permission.from_string("invalid") - - with pytest.raises(ValueError, match="Unknown permission level: owner"): - Permission.from_string("owner") - - -@pytest.mark.asyncio -class TestGetUserPermission: - async def test_collaborator_with_admin_permission(self): - gh = create_autospec(AsyncGitHubAPI, instance=True) - gh.getitem = AsyncMock(return_value={"permission": "admin"}) - event = create_test_event("user", "owner", "repo") - - permission = await aget_user_permission_from_event(event, gh) - - assert permission == Permission.ADMIN - gh.getitem.assert_called_once_with( - "/repos/owner/repo/collaborators/user/permission" - ) - - async def test_collaborator_with_write_permission(self): - gh = create_autospec(AsyncGitHubAPI, instance=True) - gh.getitem = AsyncMock(return_value={"permission": "write"}) - event = create_test_event("user", "owner", "repo") - - permission = await aget_user_permission_from_event(event, gh) - - assert permission == Permission.WRITE - - async def test_non_collaborator_public_repo(self): - gh = create_autospec(AsyncGitHubAPI, instance=True) - # First call returns 404 (not a collaborator) - gh.getitem = AsyncMock( - side_effect=[ - gidgethub.HTTPException(404, "Not found", {}), - {"private": False}, # Repo is public - ] - ) - - event = create_test_event("user", "owner", "repo") - permission = await aget_user_permission_from_event(event, gh) - - assert permission == Permission.READ - assert gh.getitem.call_count == 2 - gh.getitem.assert_any_call("/repos/owner/repo/collaborators/user/permission") - gh.getitem.assert_any_call("/repos/owner/repo") - - async def test_non_collaborator_private_repo(self): - gh = create_autospec(AsyncGitHubAPI, instance=True) - # First call returns 404 (not a collaborator) - gh.getitem = AsyncMock( - side_effect=[ - gidgethub.HTTPException(404, "Not found", {}), - {"private": True}, # Repo is private - ] - ) - event = create_test_event("user", "owner", "repo") - - permission = await aget_user_permission_from_event(event, gh) - - assert permission == Permission.NONE - - async def test_api_error_returns_none_permission(self): - gh = create_autospec(AsyncGitHubAPI, instance=True) - gh.getitem = AsyncMock( - side_effect=gidgethub.HTTPException(500, "Server error", {}) - ) - event = create_test_event("user", "owner", "repo") - - permission = await aget_user_permission_from_event(event, gh) - - assert permission == Permission.NONE - - async def test_missing_permission_field(self): - gh = create_autospec(AsyncGitHubAPI, instance=True) - gh.getitem = AsyncMock(return_value={}) # No permission field - event = create_test_event("user", "owner", "repo") - - permission = await aget_user_permission_from_event(event, gh) - - assert permission == Permission.NONE - - -class TestGetUserPermissionSync: - def test_collaborator_with_permission(self): - gh = create_autospec(SyncGitHubAPI, instance=True) - gh.getitem = Mock(return_value={"permission": "maintain"}) - event = create_test_event("user", "owner", "repo") - - permission = get_user_permission_from_event(event, gh) - - assert permission == Permission.MAINTAIN - gh.getitem.assert_called_once_with( - "/repos/owner/repo/collaborators/user/permission" - ) - - def test_non_collaborator_public_repo(self): - gh = create_autospec(SyncGitHubAPI, instance=True) - # First call returns 404 (not a collaborator) - gh.getitem = Mock( - side_effect=[ - gidgethub.HTTPException(404, "Not found", {}), - {"private": False}, # Repo is public - ] - ) - event = create_test_event("user", "owner", "repo") - - permission = get_user_permission_from_event(event, gh) - - assert permission == Permission.READ - - -class TestPermissionCaching: - @pytest.mark.asyncio - async def test_cache_hit(self): - gh = create_autospec(AsyncGitHubAPI, instance=True) - gh.getitem = AsyncMock(return_value={"permission": "write"}) - event = create_test_event("user", "owner", "repo") - - # First call should hit the API - perm1 = await aget_user_permission_from_event(event, gh) - assert perm1 == Permission.WRITE - assert gh.getitem.call_count == 1 - - # Second call should use cache - perm2 = await aget_user_permission_from_event(event, gh) - assert perm2 == Permission.WRITE - assert gh.getitem.call_count == 1 # No additional API call - - @pytest.mark.asyncio - async def test_cache_different_users(self): - gh = create_autospec(AsyncGitHubAPI, instance=True) - gh.getitem = AsyncMock( - side_effect=[ - {"permission": "write"}, - {"permission": "admin"}, - ] - ) - event1 = create_test_event("user1", "owner", "repo") - event2 = create_test_event("user2", "owner", "repo") - - perm1 = await aget_user_permission_from_event(event1, gh) - perm2 = await aget_user_permission_from_event(event2, gh) - - assert perm1 == Permission.WRITE - assert perm2 == Permission.ADMIN - assert gh.getitem.call_count == 2 - - def test_sync_cache_hit(self): - """Test that sync version uses cache.""" - gh = create_autospec(SyncGitHubAPI, instance=True) - gh.getitem = Mock(return_value={"permission": "read"}) - event = create_test_event("user", "owner", "repo") - - # First call should hit the API - perm1 = get_user_permission_from_event(event, gh) - assert perm1 == Permission.READ - assert gh.getitem.call_count == 1 - - # Second call should use cache - perm2 = get_user_permission_from_event(event, gh) - assert perm2 == Permission.READ - assert gh.getitem.call_count == 1 # No additional API call - - -class TestPermissionFromEvent: - @pytest.mark.asyncio - async def test_missing_comment_data(self): - """Test when event has no comment data.""" - gh = create_autospec(AsyncGitHubAPI, instance=True) - event = sansio.Event({}, event="issue_comment", delivery_id="test") - - permission = await aget_user_permission_from_event(event, gh) - - assert permission == Permission.NONE - assert gh.getitem.called is False - - @pytest.mark.asyncio - async def test_missing_repository_data(self): - """Test when event has no repository data.""" - gh = create_autospec(AsyncGitHubAPI, instance=True) - event = sansio.Event( - {"comment": {"user": {"login": "user"}}}, - event="issue_comment", - delivery_id="test", - ) - - permission = await aget_user_permission_from_event(event, gh) - - assert permission == Permission.NONE - assert gh.getitem.called is False diff --git a/tests/test_routing.py b/tests/test_routing.py index c5e9827..cf986c1 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -3,7 +3,6 @@ import asyncio import re -import gidgethub import pytest from django.http import HttpRequest from django.http import JsonResponse @@ -11,18 +10,10 @@ from django_github_app.github import SyncGitHubAPI from django_github_app.mentions import MentionScope -from django_github_app.permissions import cache from django_github_app.routing import GitHubRouter from django_github_app.views import BaseWebhookView -@pytest.fixture(autouse=True) -def clear_permission_cache(): - cache.clear() - yield - cache.clear() - - @pytest.fixture(autouse=True) def test_router(): import django_github_app.views @@ -126,7 +117,7 @@ def test_router_memory_stress_test_legacy(self): class TestMentionDecorator: - def test_basic_mention_no_command(self, test_router, get_mock_github_api_sync): + def test_basic_mention_no_pattern(self, test_router, get_mock_github_api_sync): handler_called = False handler_args = None @@ -146,17 +137,17 @@ def handle_mention(event, *args, **kwargs): event="issue_comment", delivery_id="123", ) - mock_gh = get_mock_github_api_sync({"permission": "write"}) + mock_gh = get_mock_github_api_sync({}) test_router.dispatch(event, mock_gh) assert handler_called assert handler_args[0] == event - def test_mention_with_command(self, test_router, get_mock_github_api_sync): + def test_mention_with_pattern(self, test_router, get_mock_github_api_sync): handler_called = False - @test_router.mention(command="help") - def help_command(event, *args, **kwargs): + @test_router.mention(pattern="help") + def help_handler(event, *args, **kwargs): nonlocal handler_called handler_called = True return "help response" @@ -171,7 +162,7 @@ def help_command(event, *args, **kwargs): event="issue_comment", delivery_id="123", ) - mock_gh = get_mock_github_api_sync({"permission": "write"}) + mock_gh = get_mock_github_api_sync({}) test_router.dispatch(event, mock_gh) assert handler_called @@ -179,12 +170,12 @@ def help_command(event, *args, **kwargs): def test_mention_with_scope(self, test_router, get_mock_github_api_sync): pr_handler_called = False - @test_router.mention(command="deploy", scope=MentionScope.PR) - def deploy_command(event, *args, **kwargs): + @test_router.mention(pattern="deploy", scope=MentionScope.PR) + def deploy_handler(event, *args, **kwargs): nonlocal pr_handler_called pr_handler_called = True - mock_gh = get_mock_github_api_sync({"permission": "write"}) + mock_gh = get_mock_github_api_sync({}) pr_event = sansio.Event( { @@ -215,37 +206,11 @@ def deploy_command(event, *args, **kwargs): assert not pr_handler_called - def test_mention_with_permission(self, test_router, get_mock_github_api_sync): + def test_case_insensitive_pattern(self, test_router, get_mock_github_api_sync): handler_called = False - @test_router.mention(command="delete", permission="admin") - def delete_command(event, *args, **kwargs): - nonlocal handler_called - handler_called = True - - event = sansio.Event( - { - "action": "created", - "comment": {"body": "@bot delete", "user": {"login": "testuser"}}, - "issue": { - "number": 123 - }, # Added issue field required for issue_comment events - "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, - }, - event="issue_comment", - delivery_id="123", - ) - # Mock the permission check to return admin permission - mock_gh = get_mock_github_api_sync({"permission": "admin"}) - test_router.dispatch(event, mock_gh) - - assert handler_called - - def test_case_insensitive_command(self, test_router, get_mock_github_api_sync): - handler_called = False - - @test_router.mention(command="HELP") - def help_command(event, *args, **kwargs): + @test_router.mention(pattern="HELP") + def help_handler(event, *args, **kwargs): nonlocal handler_called handler_called = True @@ -259,7 +224,7 @@ def help_command(event, *args, **kwargs): event="issue_comment", delivery_id="123", ) - mock_gh = get_mock_github_api_sync({"permission": "write"}) + mock_gh = get_mock_github_api_sync({}) test_router.dispatch(event, mock_gh) assert handler_called @@ -278,8 +243,8 @@ def test_multiple_decorators_on_same_function( # Track which handler is being called call_tracker = [] - @router.mention(command="help") - def help_command_help(event, *args, **kwargs): + @router.mention(pattern="help") + def help_handler_help(event, *args, **kwargs): call_tracker.append("help decorator") mention = kwargs.get("mention") if mention and mention.triggered_by: @@ -287,8 +252,8 @@ def help_command_help(event, *args, **kwargs): if text in call_counts: call_counts[text] += 1 - @router.mention(command="h") - def help_command_h(event, *args, **kwargs): + @router.mention(pattern="h") + def help_handler_h(event, *args, **kwargs): call_tracker.append("h decorator") mention = kwargs.get("mention") if mention and mention.triggered_by: @@ -296,8 +261,8 @@ def help_command_h(event, *args, **kwargs): if text in call_counts: call_counts[text] += 1 - @router.mention(command="?") - def help_command_q(event, *args, **kwargs): + @router.mention(pattern="?") + def help_handler_q(event, *args, **kwargs): call_tracker.append("? decorator") mention = kwargs.get("mention") if mention and mention.triggered_by: @@ -305,22 +270,21 @@ def help_command_q(event, *args, **kwargs): if text in call_counts: call_counts[text] += 1 - # Test each command - for command_text in ["help", "h", "?"]: + for pattern in ["help", "h", "?"]: event = sansio.Event( { "action": "created", "comment": { - "body": f"@bot {command_text}", + "body": f"@bot {pattern}", "user": {"login": "testuser"}, }, "issue": {"number": 5}, "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, }, event="issue_comment", - delivery_id=f"123-{command_text}", + delivery_id=f"123-{pattern}", ) - mock_gh = get_mock_github_api_sync({"permission": "write"}) + mock_gh = get_mock_github_api_sync({}) router.dispatch(event, mock_gh) # Check expected behavior: @@ -334,7 +298,7 @@ def help_command_q(event, *args, **kwargs): def test_async_mention_handler(self, test_router, get_mock_github_api): handler_called = False - @test_router.mention(command="async-test") + @test_router.mention(pattern="async-test") async def async_handler(event, *args, **kwargs): nonlocal handler_called handler_called = True @@ -351,7 +315,7 @@ async def async_handler(event, *args, **kwargs): delivery_id="123", ) - mock_gh = get_mock_github_api({"permission": "write"}) + mock_gh = get_mock_github_api({}) asyncio.run(test_router.adispatch(event, mock_gh)) assert handler_called @@ -359,7 +323,7 @@ async def async_handler(event, *args, **kwargs): def test_sync_mention_handler(self, test_router, get_mock_github_api_sync): handler_called = False - @test_router.mention(command="sync-test") + @test_router.mention(pattern="sync-test") def sync_handler(event, *args, **kwargs): nonlocal handler_called handler_called = True @@ -375,7 +339,7 @@ def sync_handler(event, *args, **kwargs): event="issue_comment", delivery_id="123", ) - mock_gh = get_mock_github_api_sync({"permission": "write"}) + mock_gh = get_mock_github_api_sync({}) test_router.dispatch(event, mock_gh) assert handler_called @@ -386,7 +350,7 @@ def test_scope_validation_issue_comment_on_issue( """Test that ISSUE scope works for actual issues.""" handler_called = False - @test_router.mention(command="issue-only", scope=MentionScope.ISSUE) + @test_router.mention(pattern="issue-only", scope=MentionScope.ISSUE) def issue_handler(event, *args, **kwargs): nonlocal handler_called handler_called = True @@ -402,7 +366,7 @@ def issue_handler(event, *args, **kwargs): event="issue_comment", delivery_id="123", ) - mock_gh = get_mock_github_api_sync({"permission": "write"}) + mock_gh = get_mock_github_api_sync({}) test_router.dispatch(event, mock_gh) assert handler_called @@ -413,7 +377,7 @@ def test_scope_validation_issue_comment_on_pr( """Test that ISSUE scope rejects PR comments.""" handler_called = False - @test_router.mention(command="issue-only", scope=MentionScope.ISSUE) + @test_router.mention(pattern="issue-only", scope=MentionScope.ISSUE) def issue_handler(event, *args, **kwargs): nonlocal handler_called handler_called = True @@ -433,7 +397,7 @@ def issue_handler(event, *args, **kwargs): event="issue_comment", delivery_id="123", ) - mock_gh = get_mock_github_api_sync({"permission": "write"}) + mock_gh = get_mock_github_api_sync({}) test_router.dispatch(event, mock_gh) assert not handler_called @@ -444,7 +408,7 @@ def test_scope_validation_pr_scope_on_pr( """Test that PR scope works for pull requests.""" handler_called = False - @test_router.mention(command="pr-only", scope=MentionScope.PR) + @test_router.mention(pattern="pr-only", scope=MentionScope.PR) def pr_handler(event, *args, **kwargs): nonlocal handler_called handler_called = True @@ -464,7 +428,7 @@ def pr_handler(event, *args, **kwargs): event="issue_comment", delivery_id="123", ) - mock_gh = get_mock_github_api_sync({"permission": "write"}) + mock_gh = get_mock_github_api_sync({}) test_router.dispatch(event, mock_gh) assert handler_called @@ -475,7 +439,7 @@ def test_scope_validation_pr_scope_on_issue( """Test that PR scope rejects issue comments.""" handler_called = False - @test_router.mention(command="pr-only", scope=MentionScope.PR) + @test_router.mention(pattern="pr-only", scope=MentionScope.PR) def pr_handler(event, *args, **kwargs): nonlocal handler_called handler_called = True @@ -491,7 +455,7 @@ def pr_handler(event, *args, **kwargs): event="issue_comment", delivery_id="123", ) - mock_gh = get_mock_github_api_sync({"permission": "write"}) + mock_gh = get_mock_github_api_sync({}) test_router.dispatch(event, mock_gh) assert not handler_called @@ -500,7 +464,7 @@ def test_scope_validation_commit_scope(self, test_router, get_mock_github_api_sy """Test that COMMIT scope works for commit comments.""" handler_called = False - @test_router.mention(command="commit-only", scope=MentionScope.COMMIT) + @test_router.mention(pattern="commit-only", scope=MentionScope.COMMIT) def commit_handler(event, *args, **kwargs): nonlocal handler_called handler_called = True @@ -516,7 +480,7 @@ def commit_handler(event, *args, **kwargs): event="commit_comment", delivery_id="123", ) - mock_gh = get_mock_github_api_sync({"permission": "write"}) + mock_gh = get_mock_github_api_sync({}) test_router.dispatch(event, mock_gh) assert handler_called @@ -525,12 +489,12 @@ def test_scope_validation_no_scope(self, test_router, get_mock_github_api_sync): """Test that no scope allows all comment types.""" call_count = 0 - @test_router.mention(command="all-contexts") + @test_router.mention(pattern="all-contexts") def all_handler(event, *args, **kwargs): nonlocal call_count call_count += 1 - mock_gh = get_mock_github_api_sync({"permission": "write"}) + mock_gh = get_mock_github_api_sync({}) # Test on issue event = sansio.Event( @@ -577,139 +541,13 @@ def all_handler(event, *args, **kwargs): assert call_count == 3 - def test_mention_enrichment_with_permission( - self, test_router, get_mock_github_api_sync - ): - """Test that mention decorator enriches kwargs with permission data.""" - handler_called = False - captured_kwargs = {} - - @test_router.mention(command="admin-only") - def admin_command(event, *args, **kwargs): - nonlocal handler_called, captured_kwargs - handler_called = True - captured_kwargs = kwargs.copy() - - event = sansio.Event( - { - "action": "created", - "comment": {"body": "@bot admin-only", "user": {"login": "testuser"}}, - "issue": {"number": 123}, - "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, - }, - event="issue_comment", - delivery_id="123", - ) - - # Mock the permission check to return admin permission - mock_gh = get_mock_github_api_sync({"permission": "admin"}) - - test_router.dispatch(event, mock_gh) - - # Handler SHOULD be called with enriched data - assert handler_called - assert "mention" in captured_kwargs - mention = captured_kwargs["mention"] - # Check the new structure - assert mention.comment.body == "@bot admin-only" - assert mention.triggered_by.text == "admin-only" - assert mention.user_permission.name == "ADMIN" - assert mention.scope.name == "ISSUE" - - def test_mention_enrichment_no_permission( - self, test_router, get_mock_github_api_sync - ): - """Test enrichment when user has no permission.""" - handler_called = False - captured_kwargs = {} - - @test_router.mention(command="write-required") - def write_command(event, *args, **kwargs): - nonlocal handler_called, captured_kwargs - handler_called = True - captured_kwargs = kwargs.copy() - - event = sansio.Event( - { - "action": "created", - "comment": { - "body": "@bot write-required", - "user": {"login": "stranger"}, - }, - "issue": {"number": 456}, - "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, - }, - event="issue_comment", - delivery_id="456", - ) - - # Mock returns 404 for non-collaborator - mock_gh = get_mock_github_api_sync({}) # Empty dict as we'll override getitem - mock_gh.getitem.side_effect = [ - gidgethub.HTTPException(404, "Not found", {}), # User is not a collaborator - {"private": True}, # Repo is private - ] - - test_router.dispatch(event, mock_gh) - - # Handler SHOULD be called with enriched data - assert handler_called - assert "mention" in captured_kwargs - mention = captured_kwargs["mention"] - # Check the new structure - assert mention.comment.body == "@bot write-required" - assert mention.triggered_by.text == "write-required" - assert mention.user_permission.name == "NONE" # User has no permission - assert mention.scope.name == "ISSUE" - - @pytest.mark.asyncio - async def test_async_mention_enrichment(self, test_router, get_mock_github_api): - """Test async mention decorator enriches kwargs.""" - handler_called = False - captured_kwargs = {} - - @test_router.mention(command="maintain-only") - async def maintain_command(event, *args, **kwargs): - nonlocal handler_called, captured_kwargs - handler_called = True - captured_kwargs = kwargs.copy() - - event = sansio.Event( - { - "action": "created", - "comment": { - "body": "@bot maintain-only", - "user": {"login": "contributor"}, - }, - "issue": {"number": 789}, - "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, - }, - event="issue_comment", - delivery_id="789", - ) - - # Mock the permission check to return maintain permission - mock_gh = get_mock_github_api({"permission": "maintain"}) - - await test_router.adispatch(event, mock_gh) - - # Handler SHOULD be called with enriched data - assert handler_called - assert "mention" in captured_kwargs - mention = captured_kwargs["mention"] - # Check the new structure - assert mention.comment.body == "@bot maintain-only" - assert mention.triggered_by.text == "maintain-only" - assert mention.user_permission.name == "MAINTAIN" - assert mention.scope.name == "ISSUE" - def test_mention_enrichment_pr_scope(self, test_router, get_mock_github_api_sync): """Test that PR comments get correct scope enrichment.""" handler_called = False captured_kwargs = {} - @test_router.mention(command="deploy") - def deploy_command(event, *args, **kwargs): + @test_router.mention(pattern="deploy") + def deploy_handler(event, *args, **kwargs): nonlocal handler_called, captured_kwargs handler_called = True captured_kwargs = kwargs.copy() @@ -731,7 +569,7 @@ def deploy_command(event, *args, **kwargs): delivery_id="999", ) - mock_gh = get_mock_github_api_sync({"permission": "write"}) + mock_gh = get_mock_github_api_sync({}) test_router.dispatch(event, mock_gh) assert handler_called @@ -740,7 +578,6 @@ def deploy_command(event, *args, **kwargs): # Check the new structure assert mention.comment.body == "@bot deploy" assert mention.triggered_by.text == "deploy" - assert mention.user_permission.name == "WRITE" assert mention.scope.name == "PR" # Should be PR, not ISSUE @@ -752,7 +589,7 @@ def test_mention_context_structure(self, test_router, get_mock_github_api_sync): handler_called = False captured_mention = None - @test_router.mention(command="test") + @test_router.mention(pattern="test") def test_handler(event, *args, **kwargs): nonlocal handler_called, captured_mention handler_called = True @@ -762,7 +599,7 @@ def test_handler(event, *args, **kwargs): { "action": "created", "comment": { - "body": "@bot test command", + "body": "@bot test", "user": {"login": "testuser"}, "created_at": "2024-01-01T12:00:00Z", "html_url": "https://github.com/test/repo/issues/1#issuecomment-123", @@ -774,7 +611,7 @@ def test_handler(event, *args, **kwargs): delivery_id="123", ) - mock_gh = get_mock_github_api_sync({"permission": "write"}) + mock_gh = get_mock_github_api_sync({}) test_router.dispatch(event, mock_gh) assert handler_called @@ -783,7 +620,7 @@ def test_handler(event, *args, **kwargs): # Check Comment object assert hasattr(captured_mention, "comment") comment = captured_mention.comment - assert comment.body == "@bot test command" + assert comment.body == "@bot test" assert comment.author == "testuser" assert comment.url == "https://github.com/test/repo/issues/1#issuecomment-123" assert len(comment.mentions) == 1 @@ -792,12 +629,11 @@ def test_handler(event, *args, **kwargs): assert hasattr(captured_mention, "triggered_by") triggered = captured_mention.triggered_by assert triggered.username == "bot" - assert triggered.text == "test command" + assert triggered.text == "test" assert triggered.position == 0 assert triggered.line_number == 1 # Check other fields still exist - assert captured_mention.user_permission.name == "WRITE" assert captured_mention.scope.name == "ISSUE" def test_multiple_mentions_triggered_by( @@ -807,7 +643,7 @@ def test_multiple_mentions_triggered_by( handler_called = False captured_mention = None - @test_router.mention(command="deploy") + @test_router.mention(pattern="deploy") def deploy_handler(event, *args, **kwargs): nonlocal handler_called, captured_mention handler_called = True @@ -829,7 +665,7 @@ def deploy_handler(event, *args, **kwargs): delivery_id="456", ) - mock_gh = get_mock_github_api_sync({"permission": "write"}) + mock_gh = get_mock_github_api_sync({}) test_router.dispatch(event, mock_gh) assert handler_called @@ -848,12 +684,12 @@ def deploy_handler(event, *args, **kwargs): assert first_mention.next_mention is second_mention assert second_mention.previous_mention is first_mention - def test_mention_without_command(self, test_router, get_mock_github_api_sync): - """Test handler with no specific command uses first mention as triggered_by.""" + def test_mention_without_pattern(self, test_router, get_mock_github_api_sync): + """Test handler with no specific pattern uses first mention as triggered_by.""" handler_called = False captured_mention = None - @test_router.mention() # No command specified + @test_router.mention() # No pattern specified def general_handler(event, *args, **kwargs): nonlocal handler_called, captured_mention handler_called = True @@ -875,7 +711,7 @@ def general_handler(event, *args, **kwargs): delivery_id="789", ) - mock_gh = get_mock_github_api_sync({"permission": "read"}) + mock_gh = get_mock_github_api_sync({}) test_router.dispatch(event, mock_gh) assert handler_called @@ -893,7 +729,7 @@ async def test_async_mention_context_structure( handler_called = False captured_mention = None - @test_router.mention(command="async-test") + @test_router.mention(pattern="async-test") async def async_handler(event, *args, **kwargs): nonlocal handler_called, captured_mention handler_called = True @@ -915,7 +751,7 @@ async def async_handler(event, *args, **kwargs): delivery_id="999", ) - mock_gh = get_mock_github_api({"permission": "admin"}) + mock_gh = get_mock_github_api({}) await test_router.adispatch(event, mock_gh) assert handler_called @@ -924,7 +760,6 @@ async def async_handler(event, *args, **kwargs): # Verify structure is the same for async assert captured_mention.comment.body == "@bot async-test now" assert captured_mention.triggered_by.text == "async-test now" - assert captured_mention.user_permission.name == "ADMIN" class TestFlexibleMentionTriggers: @@ -955,7 +790,7 @@ def deploy_handler(event, *args, **kwargs): event="issue_comment", delivery_id="1", ) - mock_gh = get_mock_github_api_sync({"permission": "write"}) + mock_gh = get_mock_github_api_sync({}) test_router.dispatch(event, mock_gh) assert handler_called @@ -989,7 +824,7 @@ def deploy_env_handler(event, *args, **kwargs): event="issue_comment", delivery_id="1", ) - mock_gh = get_mock_github_api_sync({"permission": "write"}) + mock_gh = get_mock_github_api_sync({}) test_router.dispatch(event, mock_gh) assert handler_called @@ -1016,7 +851,7 @@ def deploy_bot_handler(event, *args, **kwargs): event="issue_comment", delivery_id="1", ) - mock_gh = get_mock_github_api_sync({"permission": "write"}) + mock_gh = get_mock_github_api_sync({}) test_router.dispatch(event, mock_gh) assert handler_called @@ -1048,7 +883,7 @@ def any_bot_handler(event, *args, **kwargs): event="issue_comment", delivery_id="1", ) - mock_gh = get_mock_github_api_sync({"permission": "write"}) + mock_gh = get_mock_github_api_sync({}) test_router.dispatch(event, mock_gh) # Should be called twice (deploy-bot and test-bot) @@ -1076,19 +911,18 @@ def all_mentions_handler(event, *args, **kwargs): event="issue_comment", delivery_id="1", ) - mock_gh = get_mock_github_api_sync({"permission": "write"}) + mock_gh = get_mock_github_api_sync({}) test_router.dispatch(event, mock_gh) assert mentions_seen == ["alice", "bob", "charlie"] def test_combined_filters(self, test_router, get_mock_github_api_sync): - """Test combining username, pattern, permission, and scope filters.""" + """Test combining username, pattern, and scope filters.""" calls = [] @test_router.mention( username=re.compile(r".*-bot"), pattern="deploy", - permission="write", scope=MentionScope.PR, ) def restricted_deploy(event, *args, **kwargs): @@ -1109,28 +943,20 @@ def make_event(body): # All conditions met event1 = make_event("@deploy-bot deploy now") - mock_gh_write = get_mock_github_api_sync({}) - - # Mock the permission API call to return "write" permission - def mock_getitem_write(path): - if "collaborators" in path and "permission" in path: - return {"permission": "write"} - return {} - - mock_gh_write.getitem = mock_getitem_write - test_router.dispatch(event1, mock_gh_write) + mock_gh = get_mock_github_api_sync({}) + test_router.dispatch(event1, mock_gh) assert len(calls) == 1 # Wrong username pattern calls.clear() event2 = make_event("@bot deploy now") - test_router.dispatch(event2, mock_gh_write) + test_router.dispatch(event2, mock_gh) assert len(calls) == 0 # Wrong pattern calls.clear() event3 = make_event("@deploy-bot help") - test_router.dispatch(event3, mock_gh_write) + test_router.dispatch(event3, mock_gh) assert len(calls) == 0 # Wrong scope (issue instead of PR) @@ -1148,30 +974,7 @@ def mock_getitem_write(path): event="issue_comment", delivery_id="1", ) - test_router.dispatch(event4, mock_gh_write) - assert len(calls) == 0 - - # Insufficient permission - calls.clear() - event5 = make_event("@deploy-bot deploy now") - - # Clear the permission cache to ensure fresh permission check - from django_github_app.permissions import cache - - cache.clear() - - # Create a mock that returns read permission for the permission check - mock_gh_read = get_mock_github_api_sync({}) - - # Mock the permission API call to return "read" permission - def mock_getitem_read(path): - if "collaborators" in path and "permission" in path: - return {"permission": "read"} - return {} - - mock_gh_read.getitem = mock_getitem_read - - test_router.dispatch(event5, mock_gh_read) + test_router.dispatch(event4, mock_gh) assert len(calls) == 0 def test_multiple_decorators_different_patterns( @@ -1197,7 +1000,7 @@ def deploy_handler(event, *args, **kwargs): event="issue_comment", delivery_id="1", ) - mock_gh = get_mock_github_api_sync({"permission": "write"}) + mock_gh = get_mock_github_api_sync({}) test_router.dispatch(event, mock_gh) assert patterns_matched == ["ship"] @@ -1224,7 +1027,7 @@ def question_handler(event, *args, **kwargs): event="issue_comment", delivery_id="1", ) - mock_gh = get_mock_github_api_sync({"permission": "write"}) + mock_gh = get_mock_github_api_sync({}) test_router.dispatch(event, mock_gh) assert questions_received == ["what is the status?"] @@ -1234,58 +1037,3 @@ def question_handler(event, *args, **kwargs): event.data["comment"]["body"] = "@bot please help" test_router.dispatch(event, mock_gh) assert questions_received == [] - - def test_permission_filter_silently_skips( - self, test_router, get_mock_github_api_sync - ): - """Test that permission filter silently skips without error.""" - handler_called = False - - @test_router.mention(permission="admin") - def admin_only(event, *args, **kwargs): - nonlocal handler_called - handler_called = True - - event = sansio.Event( - { - "action": "created", - "comment": {"body": "@bot admin command", "user": {"login": "user"}}, - "issue": {"number": 1}, - "repository": {"owner": {"login": "owner"}, "name": "repo"}, - }, - event="issue_comment", - delivery_id="1", - ) - - # User has write permission (less than admin) - mock_gh = get_mock_github_api_sync({"permission": "write"}) - test_router.dispatch(event, mock_gh) - - # Should not be called, but no error - assert not handler_called - - def test_backward_compatibility_command( - self, test_router, get_mock_github_api_sync - ): - """Test that old 'command' parameter still works.""" - handler_called = False - - @test_router.mention(command="help") # Old style - def help_handler(event, *args, **kwargs): - nonlocal handler_called - handler_called = True - - event = sansio.Event( - { - "action": "created", - "comment": {"body": "@bot help me", "user": {"login": "user"}}, - "issue": {"number": 1}, - "repository": {"owner": {"login": "owner"}, "name": "repo"}, - }, - event="issue_comment", - delivery_id="1", - ) - mock_gh = get_mock_github_api_sync({"permission": "write"}) - test_router.dispatch(event, mock_gh) - - assert handler_called From 9bf771a18d7a597f83361f799825d9c27cbba14a Mon Sep 17 00:00:00 2001 From: Josh Date: Wed, 18 Jun 2025 20:31:34 -0500 Subject: [PATCH 12/28] Rename mention decorator kwarg from mention to context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the kwarg passed to mention handlers from "mention" to "context" to better reflect that it contains a MentionContext object with comment, triggered_by, and scope fields. Also removes unused _mention_permission attribute from MentionHandlerBase protocol. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/django_github_app/routing.py | 5 ++--- tests/test_routing.py | 28 ++++++++++++++-------------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/src/django_github_app/routing.py b/src/django_github_app/routing.py index 36982f7..00a480b 100644 --- a/src/django_github_app/routing.py +++ b/src/django_github_app/routing.py @@ -32,7 +32,6 @@ class MentionHandlerBase(Protocol): _mention_pattern: str | re.Pattern[str] | None - _mention_permission: str | None _mention_scope: MentionScope | None _mention_username: str | re.Pattern[str] | None @@ -103,7 +102,7 @@ async def async_wrapper( continue mention.match = match - kwargs["mention"] = MentionContext( + kwargs["context"] = MentionContext( comment=comment, triggered_by=mention, scope=event_scope, @@ -133,7 +132,7 @@ def sync_wrapper( continue mention.match = match - kwargs["mention"] = MentionContext( + kwargs["context"] = MentionContext( comment=comment, triggered_by=mention, scope=event_scope, diff --git a/tests/test_routing.py b/tests/test_routing.py index cf986c1..e3962b7 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -246,7 +246,7 @@ def test_multiple_decorators_on_same_function( @router.mention(pattern="help") def help_handler_help(event, *args, **kwargs): call_tracker.append("help decorator") - mention = kwargs.get("mention") + mention = kwargs.get("context") if mention and mention.triggered_by: text = mention.triggered_by.text.strip() if text in call_counts: @@ -255,7 +255,7 @@ def help_handler_help(event, *args, **kwargs): @router.mention(pattern="h") def help_handler_h(event, *args, **kwargs): call_tracker.append("h decorator") - mention = kwargs.get("mention") + mention = kwargs.get("context") if mention and mention.triggered_by: text = mention.triggered_by.text.strip() if text in call_counts: @@ -264,7 +264,7 @@ def help_handler_h(event, *args, **kwargs): @router.mention(pattern="?") def help_handler_q(event, *args, **kwargs): call_tracker.append("? decorator") - mention = kwargs.get("mention") + mention = kwargs.get("context") if mention and mention.triggered_by: text = mention.triggered_by.text.strip() if text in call_counts: @@ -573,8 +573,8 @@ def deploy_handler(event, *args, **kwargs): test_router.dispatch(event, mock_gh) assert handler_called - assert "mention" in captured_kwargs - mention = captured_kwargs["mention"] + assert "context" in captured_kwargs + mention = captured_kwargs["context"] # Check the new structure assert mention.comment.body == "@bot deploy" assert mention.triggered_by.text == "deploy" @@ -593,7 +593,7 @@ def test_mention_context_structure(self, test_router, get_mock_github_api_sync): def test_handler(event, *args, **kwargs): nonlocal handler_called, captured_mention handler_called = True - captured_mention = kwargs.get("mention") + captured_mention = kwargs.get("context") event = sansio.Event( { @@ -647,7 +647,7 @@ def test_multiple_mentions_triggered_by( def deploy_handler(event, *args, **kwargs): nonlocal handler_called, captured_mention handler_called = True - captured_mention = kwargs.get("mention") + captured_mention = kwargs.get("context") event = sansio.Event( { @@ -693,7 +693,7 @@ def test_mention_without_pattern(self, test_router, get_mock_github_api_sync): def general_handler(event, *args, **kwargs): nonlocal handler_called, captured_mention handler_called = True - captured_mention = kwargs.get("mention") + captured_mention = kwargs.get("context") event = sansio.Event( { @@ -733,7 +733,7 @@ async def test_async_mention_context_structure( async def async_handler(event, *args, **kwargs): nonlocal handler_called, captured_mention handler_called = True - captured_mention = kwargs.get("mention") + captured_mention = kwargs.get("context") event = sansio.Event( { @@ -774,7 +774,7 @@ def test_pattern_parameter_string(self, test_router, get_mock_github_api_sync): def deploy_handler(event, *args, **kwargs): nonlocal handler_called, captured_mention handler_called = True - captured_mention = kwargs.get("mention") + captured_mention = kwargs.get("context") # Should match event = sansio.Event( @@ -812,7 +812,7 @@ def test_pattern_parameter_regex(self, test_router, get_mock_github_api_sync): def deploy_env_handler(event, *args, **kwargs): nonlocal handler_called, captured_mention handler_called = True - captured_mention = kwargs.get("mention") + captured_mention = kwargs.get("context") event = sansio.Event( { @@ -895,7 +895,7 @@ def test_username_all_mentions(self, test_router, get_mock_github_api_sync): @test_router.mention(username=re.compile(r".*")) def all_mentions_handler(event, *args, **kwargs): - mention = kwargs.get("mention") + mention = kwargs.get("context") mentions_seen.append(mention.triggered_by.username) event = sansio.Event( @@ -987,7 +987,7 @@ def test_multiple_decorators_different_patterns( @test_router.mention(pattern=re.compile(r"ship")) @test_router.mention(pattern=re.compile(r"release")) def deploy_handler(event, *args, **kwargs): - mention = kwargs.get("mention") + mention = kwargs.get("context") patterns_matched.append(mention.triggered_by.text.split()[0]) event = sansio.Event( @@ -1011,7 +1011,7 @@ def test_question_pattern(self, test_router, get_mock_github_api_sync): @test_router.mention(pattern=re.compile(r".*\?$")) def question_handler(event, *args, **kwargs): - mention = kwargs.get("mention") + mention = kwargs.get("context") questions_received.append(mention.triggered_by.text) event = sansio.Event( From d68d0b012d52d38f4a46a4ca9077947883987f91 Mon Sep 17 00:00:00 2001 From: Josh Date: Wed, 18 Jun 2025 20:36:46 -0500 Subject: [PATCH 13/28] Refactor get_event_scope to MentionScope.from_event classmethod MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the get_event_scope function to be a classmethod on MentionScope called from_event, following the same pattern as Comment.from_event. This provides a more consistent API and better encapsulation. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/django_github_app/mentions.py | 31 +++++++++++++----------- src/django_github_app/routing.py | 5 ++-- tests/test_mentions.py | 39 +++++++++++++++---------------- 3 files changed, 38 insertions(+), 37 deletions(-) diff --git a/src/django_github_app/mentions.py b/src/django_github_app/mentions.py index 5fa53a2..2995eb5 100644 --- a/src/django_github_app/mentions.py +++ b/src/django_github_app/mentions.py @@ -46,6 +46,23 @@ def all_events(cls) -> list[EventAction]: ) ) + @classmethod + def from_event(cls, event: sansio.Event) -> MentionScope | None: + """Determine the scope of a GitHub event based on its type and context.""" + if event.event == "issue_comment": + issue = event.data.get("issue", {}) + is_pull_request = ( + "pull_request" in issue and issue["pull_request"] is not None + ) + return cls.PR if is_pull_request else cls.ISSUE + + for scope in cls: + scope_events = scope.get_events() + if any(event_action.event == event.event for event_action in scope_events): + return scope + + return None + @dataclass class Mention: @@ -127,20 +144,6 @@ class MentionContext: QUOTE_PATTERN = re.compile(r"^\s*>.*$", re.MULTILINE) -def get_event_scope(event: sansio.Event) -> MentionScope | None: - if event.event == "issue_comment": - issue = event.data.get("issue", {}) - is_pull_request = "pull_request" in issue and issue["pull_request"] is not None - return MentionScope.PR if is_pull_request else MentionScope.ISSUE - - for scope in MentionScope: - scope_events = scope.get_events() - if any(event_action.event == event.event for event_action in scope_events): - return scope - - return None - - def check_pattern_match( text: str, pattern: str | re.Pattern[str] | None ) -> re.Match[str] | None: diff --git a/src/django_github_app/routing.py b/src/django_github_app/routing.py index 00a480b..ffc7afa 100644 --- a/src/django_github_app/routing.py +++ b/src/django_github_app/routing.py @@ -21,7 +21,6 @@ from .mentions import MentionContext from .mentions import MentionScope from .mentions import check_pattern_match -from .mentions import get_event_scope from .mentions import parse_mentions_for_username AsyncCallback = Callable[..., Awaitable[None]] @@ -84,7 +83,7 @@ def decorator(func: CB) -> CB: async def async_wrapper( event: sansio.Event, gh: AsyncGitHubAPI, *args: Any, **kwargs: Any ) -> None: - event_scope = get_event_scope(event) + event_scope = MentionScope.from_event(event) if scope is not None and event_scope != scope: return @@ -114,7 +113,7 @@ async def async_wrapper( def sync_wrapper( event: sansio.Event, gh: SyncGitHubAPI, *args: Any, **kwargs: Any ) -> None: - event_scope = get_event_scope(event) + event_scope = MentionScope.from_event(event) if scope is not None and event_scope != scope: return diff --git a/tests/test_mentions.py b/tests/test_mentions.py index bacc60d..c281e23 100644 --- a/tests/test_mentions.py +++ b/tests/test_mentions.py @@ -8,7 +8,6 @@ from django_github_app.mentions import Comment from django_github_app.mentions import MentionScope -from django_github_app.mentions import get_event_scope from django_github_app.mentions import parse_mentions_for_username @@ -188,25 +187,25 @@ def test_special_character_command(self, create_comment_event): class TestGetEventScope: - def test_get_event_scope_for_various_events(self): + def test_from_event_for_various_events(self): # Issue comment on actual issue event1 = sansio.Event({"issue": {}}, event="issue_comment", delivery_id="1") - assert get_event_scope(event1) == MentionScope.ISSUE + assert MentionScope.from_event(event1) == MentionScope.ISSUE # PR review comment event2 = sansio.Event({}, event="pull_request_review_comment", delivery_id="2") - assert get_event_scope(event2) == MentionScope.PR + assert MentionScope.from_event(event2) == MentionScope.PR # Commit comment event3 = sansio.Event({}, event="commit_comment", delivery_id="3") - assert get_event_scope(event3) == MentionScope.COMMIT + assert MentionScope.from_event(event3) == MentionScope.COMMIT def test_issue_scope_on_issue_comment(self): # Issue comment on an actual issue (no pull_request field) issue_event = sansio.Event( {"issue": {"title": "Bug report"}}, event="issue_comment", delivery_id="1" ) - assert get_event_scope(issue_event) == MentionScope.ISSUE + assert MentionScope.from_event(issue_event) == MentionScope.ISSUE # Issue comment on a pull request (has pull_request field) pr_event = sansio.Event( @@ -214,14 +213,14 @@ def test_issue_scope_on_issue_comment(self): event="issue_comment", delivery_id="2", ) - assert get_event_scope(pr_event) == MentionScope.PR + assert MentionScope.from_event(pr_event) == MentionScope.PR def test_pr_scope_on_issue_comment(self): # Issue comment on an actual issue (no pull_request field) issue_event = sansio.Event( {"issue": {"title": "Bug report"}}, event="issue_comment", delivery_id="1" ) - assert get_event_scope(issue_event) == MentionScope.ISSUE + assert MentionScope.from_event(issue_event) == MentionScope.ISSUE # Issue comment on a pull request (has pull_request field) pr_event = sansio.Event( @@ -229,42 +228,42 @@ def test_pr_scope_on_issue_comment(self): event="issue_comment", delivery_id="2", ) - assert get_event_scope(pr_event) == MentionScope.PR + assert MentionScope.from_event(pr_event) == MentionScope.PR def test_pr_scope_allows_pr_specific_events(self): # PR scope should allow pull_request_review_comment event1 = sansio.Event({}, event="pull_request_review_comment", delivery_id="1") - assert get_event_scope(event1) == MentionScope.PR + assert MentionScope.from_event(event1) == MentionScope.PR # PR scope should allow pull_request_review event2 = sansio.Event({}, event="pull_request_review", delivery_id="2") - assert get_event_scope(event2) == MentionScope.PR + assert MentionScope.from_event(event2) == MentionScope.PR # PR scope should not allow commit_comment event3 = sansio.Event({}, event="commit_comment", delivery_id="3") - assert get_event_scope(event3) == MentionScope.COMMIT + assert MentionScope.from_event(event3) == MentionScope.COMMIT def test_commit_scope_allows_commit_comment_only(self): # Commit scope should allow commit_comment event1 = sansio.Event({}, event="commit_comment", delivery_id="1") - assert get_event_scope(event1) == MentionScope.COMMIT + assert MentionScope.from_event(event1) == MentionScope.COMMIT # Commit scope should not allow issue_comment event2 = sansio.Event({"issue": {}}, event="issue_comment", delivery_id="2") - assert get_event_scope(event2) == MentionScope.ISSUE + assert MentionScope.from_event(event2) == MentionScope.ISSUE # Commit scope should not allow PR events event3 = sansio.Event({}, event="pull_request_review_comment", delivery_id="3") - assert get_event_scope(event3) == MentionScope.PR + assert MentionScope.from_event(event3) == MentionScope.PR def test_different_event_types_have_correct_scope(self): # pull_request_review_comment should be PR scope event1 = sansio.Event({}, event="pull_request_review_comment", delivery_id="1") - assert get_event_scope(event1) == MentionScope.PR + assert MentionScope.from_event(event1) == MentionScope.PR # commit_comment should be COMMIT scope event2 = sansio.Event({}, event="commit_comment", delivery_id="2") - assert get_event_scope(event2) == MentionScope.COMMIT + assert MentionScope.from_event(event2) == MentionScope.COMMIT def test_pull_request_field_none_treated_as_issue(self): # If pull_request field exists but is None, treat as issue @@ -273,17 +272,17 @@ def test_pull_request_field_none_treated_as_issue(self): event="issue_comment", delivery_id="1", ) - assert get_event_scope(event) == MentionScope.ISSUE + assert MentionScope.from_event(event) == MentionScope.ISSUE def test_missing_issue_data(self): # If issue data is missing entirely, defaults to ISSUE scope for issue_comment event = sansio.Event({}, event="issue_comment", delivery_id="1") - assert get_event_scope(event) == MentionScope.ISSUE + assert MentionScope.from_event(event) == MentionScope.ISSUE def test_unknown_event_returns_none(self): # Unknown event types should return None event = sansio.Event({}, event="unknown_event", delivery_id="1") - assert get_event_scope(event) is None + assert MentionScope.from_event(event) is None class TestComment: From cde6827f19b1b746078234c9bdfd88d8cccd436c Mon Sep 17 00:00:00 2001 From: Josh Date: Wed, 18 Jun 2025 22:25:38 -0500 Subject: [PATCH 14/28] Refactor mention system for cleaner API and better encapsulation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add explicit kwargs to mention() decorator (pattern, username, scope) - Pass context as explicit parameter instead of mutating kwargs - Create MentionEvent.from_event() generator to encapsulate all mention processing logic (parsing, filtering, context creation) - Rename MentionContext to MentionEvent for clarity - Simplify router code from ~30 lines to 4 lines per wrapper This creates a much cleaner API where the mention decorator's wrappers simply iterate over MentionEvent instances yielded by the generator. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/django_github_app/mentions.py | 44 +++++++++++++++++- src/django_github_app/routing.py | 76 +++++++------------------------ tests/test_routing.py | 6 +-- 3 files changed, 63 insertions(+), 63 deletions(-) diff --git a/src/django_github_app/mentions.py b/src/django_github_app/mentions.py index 2995eb5..1954d3e 100644 --- a/src/django_github_app/mentions.py +++ b/src/django_github_app/mentions.py @@ -133,11 +133,53 @@ def from_event(cls, event: sansio.Event) -> Comment: @dataclass -class MentionContext: +class MentionEvent: comment: Comment triggered_by: Mention scope: MentionScope | None + @classmethod + def from_event( + cls, + event: sansio.Event, + *, + username: str | re.Pattern[str] | None = None, + pattern: str | re.Pattern[str] | None = None, + scope: MentionScope | None = None, + ): + """Generate MentionEvent instances from a GitHub event. + + Yields MentionEvent for each mention that matches the given criteria. + """ + # Check scope match first + event_scope = MentionScope.from_event(event) + if scope is not None and event_scope != scope: + return + + # Parse mentions + mentions = parse_mentions_for_username(event, username) + if not mentions: + return + + # Create comment + comment = Comment.from_event(event) + comment.mentions = mentions + + # Yield contexts for matching mentions + for mention in mentions: + # Check pattern match if specified + if pattern is not None: + match = check_pattern_match(mention.text, pattern) + if not match: + continue + mention.match = match + + yield cls( + comment=comment, + triggered_by=mention, + scope=event_scope, + ) + CODE_BLOCK_PATTERN = re.compile(r"```[\s\S]*?```", re.MULTILINE) INLINE_CODE_PATTERN = re.compile(r"`[^`]+`") diff --git a/src/django_github_app/routing.py b/src/django_github_app/routing.py index ffc7afa..3c31e03 100644 --- a/src/django_github_app/routing.py +++ b/src/django_github_app/routing.py @@ -17,11 +17,8 @@ from ._typing import override from .github import AsyncGitHubAPI from .github import SyncGitHubAPI -from .mentions import Comment -from .mentions import MentionContext +from .mentions import MentionEvent from .mentions import MentionScope -from .mentions import check_pattern_match -from .mentions import parse_mentions_for_username AsyncCallback = Callable[..., Awaitable[None]] SyncCallback = Callable[..., None] @@ -73,71 +70,32 @@ def decorator(func: CB) -> CB: return decorator - def mention(self, **kwargs: Any) -> Callable[[CB], CB]: + def mention( + self, + *, + pattern: str | re.Pattern[str] | None = None, + username: str | re.Pattern[str] | None = None, + scope: MentionScope | None = None, + **kwargs: Any, + ) -> Callable[[CB], CB]: def decorator(func: CB) -> CB: - pattern = kwargs.pop("pattern", None) - username = kwargs.pop("username", None) - scope = kwargs.pop("scope", None) - @wraps(func) async def async_wrapper( event: sansio.Event, gh: AsyncGitHubAPI, *args: Any, **kwargs: Any ) -> None: - event_scope = MentionScope.from_event(event) - if scope is not None and event_scope != scope: - return - - mentions = parse_mentions_for_username(event, username) - if not mentions: - return - - comment = Comment.from_event(event) - comment.mentions = mentions - - for mention in mentions: - if pattern is not None: - match = check_pattern_match(mention.text, pattern) - if not match: - continue - mention.match = match - - kwargs["context"] = MentionContext( - comment=comment, - triggered_by=mention, - scope=event_scope, - ) - - await func(event, gh, *args, **kwargs) # type: ignore[func-returns-value] + for context in MentionEvent.from_event( + event, username=username, pattern=pattern, scope=scope + ): + await func(event, gh, *args, context=context, **kwargs) # type: ignore[func-returns-value] @wraps(func) def sync_wrapper( event: sansio.Event, gh: SyncGitHubAPI, *args: Any, **kwargs: Any ) -> None: - event_scope = MentionScope.from_event(event) - if scope is not None and event_scope != scope: - return - - mentions = parse_mentions_for_username(event, username) - if not mentions: - return - - comment = Comment.from_event(event) - comment.mentions = mentions - - for mention in mentions: - if pattern is not None: - match = check_pattern_match(mention.text, pattern) - if not match: - continue - mention.match = match - - kwargs["context"] = MentionContext( - comment=comment, - triggered_by=mention, - scope=event_scope, - ) - - func(event, gh, *args, **kwargs) + for context in MentionEvent.from_event( + event, username=username, pattern=pattern, scope=scope + ): + func(event, gh, *args, context=context, **kwargs) wrapper: MentionHandler if iscoroutinefunction(func): diff --git a/tests/test_routing.py b/tests/test_routing.py index e3962b7..2194a66 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -582,10 +582,10 @@ def deploy_handler(event, *args, **kwargs): class TestUpdatedMentionContext: - """Test the updated MentionContext structure with comment and triggered_by fields.""" + """Test the updated MentionEvent structure with comment and triggered_by fields.""" def test_mention_context_structure(self, test_router, get_mock_github_api_sync): - """Test that MentionContext has the new structure with comment and triggered_by.""" + """Test that MentionEvent has the new structure with comment and triggered_by.""" handler_called = False captured_mention = None @@ -725,7 +725,7 @@ def general_handler(event, *args, **kwargs): async def test_async_mention_context_structure( self, test_router, get_mock_github_api ): - """Test async handlers get the same updated MentionContext structure.""" + """Test async handlers get the same updated MentionEvent structure.""" handler_called = False captured_mention = None From a4e58e62d7ec641b938a3bfdaa9d26f7d316319a Mon Sep 17 00:00:00 2001 From: Josh Date: Wed, 18 Jun 2025 22:37:59 -0500 Subject: [PATCH 15/28] Reorder mentions.py for better code organization --- src/django_github_app/mentions.py | 212 ++++++++++++++---------------- 1 file changed, 102 insertions(+), 110 deletions(-) diff --git a/src/django_github_app/mentions.py b/src/django_github_app/mentions.py index 1954d3e..027cdad 100644 --- a/src/django_github_app/mentions.py +++ b/src/django_github_app/mentions.py @@ -76,116 +76,6 @@ class Mention: next_mention: Mention | None = None -@dataclass -class Comment: - body: str - author: str - created_at: datetime - url: str - mentions: list[Mention] - - @property - def line_count(self) -> int: - """Number of lines in the comment.""" - if not self.body: - return 0 - return len(self.body.splitlines()) - - @classmethod - def from_event(cls, event: sansio.Event) -> Comment: - match event.event: - case "issue_comment" | "pull_request_review_comment" | "commit_comment": - comment_data = event.data.get("comment") - case "pull_request_review": - comment_data = event.data.get("review") - case _: - comment_data = None - - if not comment_data: - raise ValueError(f"Cannot extract comment from event type: {event.event}") - - created_at_str = comment_data.get("created_at", "") - if created_at_str: - # GitHub timestamps are in ISO format: 2024-01-01T12:00:00Z - created_at_aware = datetime.fromisoformat( - created_at_str.replace("Z", "+00:00") - ) - if settings.USE_TZ: - created_at = created_at_aware - else: - created_at = timezone.make_naive( - created_at_aware, timezone.get_default_timezone() - ) - else: - created_at = timezone.now() - - author = comment_data.get("user", {}).get("login", "") - if not author and "sender" in event.data: - author = event.data.get("sender", {}).get("login", "") - - return cls( - body=comment_data.get("body", ""), - author=author, - created_at=created_at, - url=comment_data.get("html_url", ""), - mentions=[], - ) - - -@dataclass -class MentionEvent: - comment: Comment - triggered_by: Mention - scope: MentionScope | None - - @classmethod - def from_event( - cls, - event: sansio.Event, - *, - username: str | re.Pattern[str] | None = None, - pattern: str | re.Pattern[str] | None = None, - scope: MentionScope | None = None, - ): - """Generate MentionEvent instances from a GitHub event. - - Yields MentionEvent for each mention that matches the given criteria. - """ - # Check scope match first - event_scope = MentionScope.from_event(event) - if scope is not None and event_scope != scope: - return - - # Parse mentions - mentions = parse_mentions_for_username(event, username) - if not mentions: - return - - # Create comment - comment = Comment.from_event(event) - comment.mentions = mentions - - # Yield contexts for matching mentions - for mention in mentions: - # Check pattern match if specified - if pattern is not None: - match = check_pattern_match(mention.text, pattern) - if not match: - continue - mention.match = match - - yield cls( - comment=comment, - triggered_by=mention, - scope=event_scope, - ) - - -CODE_BLOCK_PATTERN = re.compile(r"```[\s\S]*?```", re.MULTILINE) -INLINE_CODE_PATTERN = re.compile(r"`[^`]+`") -QUOTE_PATTERN = re.compile(r"^\s*>.*$", re.MULTILINE) - - def check_pattern_match( text: str, pattern: str | re.Pattern[str] | None ) -> re.Match[str] | None: @@ -208,6 +98,11 @@ def check_pattern_match( return re.match(escaped_pattern, text, re.IGNORECASE) +CODE_BLOCK_PATTERN = re.compile(r"```[\s\S]*?```", re.MULTILINE) +INLINE_CODE_PATTERN = re.compile(r"`[^`]+`") +QUOTE_PATTERN = re.compile(r"^\s*>.*$", re.MULTILINE) + + def parse_mentions_for_username( event: sansio.Event, username_pattern: str | re.Pattern[str] | None = None ) -> list[Mention]: @@ -299,3 +194,100 @@ def parse_mentions_for_username( mention.next_mention = mentions[i + 1] return mentions + + +@dataclass +class Comment: + body: str + author: str + created_at: datetime + url: str + mentions: list[Mention] + + @property + def line_count(self) -> int: + """Number of lines in the comment.""" + if not self.body: + return 0 + return len(self.body.splitlines()) + + @classmethod + def from_event(cls, event: sansio.Event) -> Comment: + match event.event: + case "issue_comment" | "pull_request_review_comment" | "commit_comment": + comment_data = event.data.get("comment") + case "pull_request_review": + comment_data = event.data.get("review") + case _: + comment_data = None + + if not comment_data: + raise ValueError(f"Cannot extract comment from event type: {event.event}") + + created_at_str = comment_data.get("created_at", "") + if created_at_str: + # GitHub timestamps are in ISO format: 2024-01-01T12:00:00Z + created_at_aware = datetime.fromisoformat( + created_at_str.replace("Z", "+00:00") + ) + if settings.USE_TZ: + created_at = created_at_aware + else: + created_at = timezone.make_naive( + created_at_aware, timezone.get_default_timezone() + ) + else: + created_at = timezone.now() + + author = comment_data.get("user", {}).get("login", "") + if not author and "sender" in event.data: + author = event.data.get("sender", {}).get("login", "") + + return cls( + body=comment_data.get("body", ""), + author=author, + created_at=created_at, + url=comment_data.get("html_url", ""), + mentions=[], + ) + + +@dataclass +class MentionEvent: + comment: Comment + triggered_by: Mention + scope: MentionScope | None + + @classmethod + def from_event( + cls, + event: sansio.Event, + *, + username: str | re.Pattern[str] | None = None, + pattern: str | re.Pattern[str] | None = None, + scope: MentionScope | None = None, + ): + event_scope = MentionScope.from_event(event) + if scope is not None and event_scope != scope: + return + + mentions = parse_mentions_for_username(event, username) + if not mentions: + return + + comment = Comment.from_event(event) + comment.mentions = mentions + + for mention in mentions: + if pattern is not None: + match = check_pattern_match(mention.text, pattern) + if not match: + continue + mention.match = match + + yield cls( + comment=comment, + triggered_by=mention, + scope=event_scope, + ) + From 560eb5db49c795f02493b953f7b575b21f83c4f7 Mon Sep 17 00:00:00 2001 From: Josh Date: Wed, 18 Jun 2025 22:58:59 -0500 Subject: [PATCH 16/28] Fix test fixtures and refactor decorator test to use stacked pattern --- tests/test_routing.py | 210 ++++++++++++++---------------------------- 1 file changed, 70 insertions(+), 140 deletions(-) diff --git a/tests/test_routing.py b/tests/test_routing.py index 2194a66..c2a7208 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -117,7 +117,7 @@ def test_router_memory_stress_test_legacy(self): class TestMentionDecorator: - def test_basic_mention_no_pattern(self, test_router, get_mock_github_api_sync): + def test_basic_mention_no_pattern(self, test_router, get_mock_github_api): handler_called = False handler_args = None @@ -137,13 +137,13 @@ def handle_mention(event, *args, **kwargs): event="issue_comment", delivery_id="123", ) - mock_gh = get_mock_github_api_sync({}) + mock_gh = get_mock_github_api({}) test_router.dispatch(event, mock_gh) assert handler_called assert handler_args[0] == event - def test_mention_with_pattern(self, test_router, get_mock_github_api_sync): + def test_mention_with_pattern(self, test_router, get_mock_github_api): handler_called = False @test_router.mention(pattern="help") @@ -162,12 +162,12 @@ def help_handler(event, *args, **kwargs): event="issue_comment", delivery_id="123", ) - mock_gh = get_mock_github_api_sync({}) + mock_gh = get_mock_github_api({}) test_router.dispatch(event, mock_gh) assert handler_called - def test_mention_with_scope(self, test_router, get_mock_github_api_sync): + def test_mention_with_scope(self, test_router, get_mock_github_api): pr_handler_called = False @test_router.mention(pattern="deploy", scope=MentionScope.PR) @@ -175,7 +175,7 @@ def deploy_handler(event, *args, **kwargs): nonlocal pr_handler_called pr_handler_called = True - mock_gh = get_mock_github_api_sync({}) + mock_gh = get_mock_github_api({}) pr_event = sansio.Event( { @@ -206,7 +206,7 @@ def deploy_handler(event, *args, **kwargs): assert not pr_handler_called - def test_case_insensitive_pattern(self, test_router, get_mock_github_api_sync): + def test_case_insensitive_pattern(self, test_router, get_mock_github_api): handler_called = False @test_router.mention(pattern="HELP") @@ -224,46 +224,20 @@ def help_handler(event, *args, **kwargs): event="issue_comment", delivery_id="123", ) - mock_gh = get_mock_github_api_sync({}) + mock_gh = get_mock_github_api({}) test_router.dispatch(event, mock_gh) assert handler_called def test_multiple_decorators_on_same_function( - self, test_router, get_mock_github_api_sync + self, test_router, get_mock_github_api ): - """Test that multiple decorators on the same function work correctly.""" - # Create a fresh router for this test - from django_github_app.routing import GitHubRouter - - router = GitHubRouter() - call_counts = {"help": 0, "h": 0, "?": 0} - # Track which handler is being called - call_tracker = [] - - @router.mention(pattern="help") - def help_handler_help(event, *args, **kwargs): - call_tracker.append("help decorator") - mention = kwargs.get("context") - if mention and mention.triggered_by: - text = mention.triggered_by.text.strip() - if text in call_counts: - call_counts[text] += 1 - - @router.mention(pattern="h") - def help_handler_h(event, *args, **kwargs): - call_tracker.append("h decorator") - mention = kwargs.get("context") - if mention and mention.triggered_by: - text = mention.triggered_by.text.strip() - if text in call_counts: - call_counts[text] += 1 - - @router.mention(pattern="?") - def help_handler_q(event, *args, **kwargs): - call_tracker.append("? decorator") + @test_router.mention(pattern="help") + @test_router.mention(pattern="h") + @test_router.mention(pattern="?") + def help_handler(event, *args, **kwargs): mention = kwargs.get("context") if mention and mention.triggered_by: text = mention.triggered_by.text.strip() @@ -284,8 +258,8 @@ def help_handler_q(event, *args, **kwargs): event="issue_comment", delivery_id=f"123-{pattern}", ) - mock_gh = get_mock_github_api_sync({}) - router.dispatch(event, mock_gh) + mock_gh = get_mock_github_api({}) + test_router.dispatch(event, mock_gh) # Check expected behavior: # - "help" matches both "help" pattern and "h" pattern (since "help" starts with "h") @@ -295,7 +269,7 @@ def help_handler_q(event, *args, **kwargs): assert call_counts["h"] == 1 # Matched only by "h" pattern assert call_counts["?"] == 1 # Matched only by "?" pattern - def test_async_mention_handler(self, test_router, get_mock_github_api): + def test_async_mention_handler(self, test_router, aget_mock_github_api): handler_called = False @test_router.mention(pattern="async-test") @@ -315,12 +289,12 @@ async def async_handler(event, *args, **kwargs): delivery_id="123", ) - mock_gh = get_mock_github_api({}) + mock_gh = aget_mock_github_api({}) asyncio.run(test_router.adispatch(event, mock_gh)) assert handler_called - def test_sync_mention_handler(self, test_router, get_mock_github_api_sync): + def test_sync_mention_handler(self, test_router, get_mock_github_api): handler_called = False @test_router.mention(pattern="sync-test") @@ -339,15 +313,14 @@ def sync_handler(event, *args, **kwargs): event="issue_comment", delivery_id="123", ) - mock_gh = get_mock_github_api_sync({}) + mock_gh = get_mock_github_api({}) test_router.dispatch(event, mock_gh) assert handler_called def test_scope_validation_issue_comment_on_issue( - self, test_router, get_mock_github_api_sync + self, test_router, get_mock_github_api ): - """Test that ISSUE scope works for actual issues.""" handler_called = False @test_router.mention(pattern="issue-only", scope=MentionScope.ISSUE) @@ -355,7 +328,6 @@ def issue_handler(event, *args, **kwargs): nonlocal handler_called handler_called = True - # Issue comment on an actual issue (no pull_request field) event = sansio.Event( { "action": "created", @@ -366,15 +338,14 @@ def issue_handler(event, *args, **kwargs): event="issue_comment", delivery_id="123", ) - mock_gh = get_mock_github_api_sync({}) + mock_gh = get_mock_github_api({}) test_router.dispatch(event, mock_gh) assert handler_called def test_scope_validation_issue_comment_on_pr( - self, test_router, get_mock_github_api_sync + self, test_router, get_mock_github_api ): - """Test that ISSUE scope rejects PR comments.""" handler_called = False @test_router.mention(pattern="issue-only", scope=MentionScope.ISSUE) @@ -397,15 +368,12 @@ def issue_handler(event, *args, **kwargs): event="issue_comment", delivery_id="123", ) - mock_gh = get_mock_github_api_sync({}) + mock_gh = get_mock_github_api({}) test_router.dispatch(event, mock_gh) assert not handler_called - def test_scope_validation_pr_scope_on_pr( - self, test_router, get_mock_github_api_sync - ): - """Test that PR scope works for pull requests.""" + def test_scope_validation_pr_scope_on_pr(self, test_router, get_mock_github_api): handler_called = False @test_router.mention(pattern="pr-only", scope=MentionScope.PR) @@ -413,7 +381,6 @@ def pr_handler(event, *args, **kwargs): nonlocal handler_called handler_called = True - # Issue comment on a pull request event = sansio.Event( { "action": "created", @@ -428,15 +395,12 @@ def pr_handler(event, *args, **kwargs): event="issue_comment", delivery_id="123", ) - mock_gh = get_mock_github_api_sync({}) + mock_gh = get_mock_github_api({}) test_router.dispatch(event, mock_gh) assert handler_called - def test_scope_validation_pr_scope_on_issue( - self, test_router, get_mock_github_api_sync - ): - """Test that PR scope rejects issue comments.""" + def test_scope_validation_pr_scope_on_issue(self, test_router, get_mock_github_api): handler_called = False @test_router.mention(pattern="pr-only", scope=MentionScope.PR) @@ -444,7 +408,6 @@ def pr_handler(event, *args, **kwargs): nonlocal handler_called handler_called = True - # Issue comment on an actual issue event = sansio.Event( { "action": "created", @@ -455,12 +418,12 @@ def pr_handler(event, *args, **kwargs): event="issue_comment", delivery_id="123", ) - mock_gh = get_mock_github_api_sync({}) + mock_gh = get_mock_github_api({}) test_router.dispatch(event, mock_gh) assert not handler_called - def test_scope_validation_commit_scope(self, test_router, get_mock_github_api_sync): + def test_scope_validation_commit_scope(self, test_router, get_mock_github_api): """Test that COMMIT scope works for commit comments.""" handler_called = False @@ -469,7 +432,6 @@ def commit_handler(event, *args, **kwargs): nonlocal handler_called handler_called = True - # Commit comment event event = sansio.Event( { "action": "created", @@ -480,13 +442,12 @@ def commit_handler(event, *args, **kwargs): event="commit_comment", delivery_id="123", ) - mock_gh = get_mock_github_api_sync({}) + mock_gh = get_mock_github_api({}) test_router.dispatch(event, mock_gh) assert handler_called - def test_scope_validation_no_scope(self, test_router, get_mock_github_api_sync): - """Test that no scope allows all comment types.""" + def test_scope_validation_no_scope(self, test_router, get_mock_github_api): call_count = 0 @test_router.mention(pattern="all-contexts") @@ -494,9 +455,8 @@ def all_handler(event, *args, **kwargs): nonlocal call_count call_count += 1 - mock_gh = get_mock_github_api_sync({}) + mock_gh = get_mock_github_api({}) - # Test on issue event = sansio.Event( { "action": "created", @@ -509,7 +469,6 @@ def all_handler(event, *args, **kwargs): ) test_router.dispatch(event, mock_gh) - # Test on PR event = sansio.Event( { "action": "created", @@ -526,7 +485,6 @@ def all_handler(event, *args, **kwargs): ) test_router.dispatch(event, mock_gh) - # Test on commit event = sansio.Event( { "action": "created", @@ -541,8 +499,7 @@ def all_handler(event, *args, **kwargs): assert call_count == 3 - def test_mention_enrichment_pr_scope(self, test_router, get_mock_github_api_sync): - """Test that PR comments get correct scope enrichment.""" + def test_mention_enrichment_pr_scope(self, test_router, get_mock_github_api): handler_called = False captured_kwargs = {} @@ -552,7 +509,6 @@ def deploy_handler(event, *args, **kwargs): handler_called = True captured_kwargs = kwargs.copy() - # Issue comment on a PR (has pull_request field) event = sansio.Event( { "action": "created", @@ -569,23 +525,21 @@ def deploy_handler(event, *args, **kwargs): delivery_id="999", ) - mock_gh = get_mock_github_api_sync({}) + mock_gh = get_mock_github_api({}) test_router.dispatch(event, mock_gh) assert handler_called assert "context" in captured_kwargs + mention = captured_kwargs["context"] - # Check the new structure + assert mention.comment.body == "@bot deploy" assert mention.triggered_by.text == "deploy" - assert mention.scope.name == "PR" # Should be PR, not ISSUE + assert mention.scope.name == "PR" class TestUpdatedMentionContext: - """Test the updated MentionEvent structure with comment and triggered_by fields.""" - - def test_mention_context_structure(self, test_router, get_mock_github_api_sync): - """Test that MentionEvent has the new structure with comment and triggered_by.""" + def test_mention_context_structure(self, test_router, get_mock_github_api): handler_called = False captured_mention = None @@ -611,35 +565,28 @@ def test_handler(event, *args, **kwargs): delivery_id="123", ) - mock_gh = get_mock_github_api_sync({}) + mock_gh = get_mock_github_api({}) test_router.dispatch(event, mock_gh) assert handler_called - assert captured_mention is not None - # Check Comment object - assert hasattr(captured_mention, "comment") comment = captured_mention.comment + assert comment.body == "@bot test" assert comment.author == "testuser" assert comment.url == "https://github.com/test/repo/issues/1#issuecomment-123" assert len(comment.mentions) == 1 - # Check triggered_by Mention object - assert hasattr(captured_mention, "triggered_by") triggered = captured_mention.triggered_by + assert triggered.username == "bot" assert triggered.text == "test" assert triggered.position == 0 assert triggered.line_number == 1 - # Check other fields still exist assert captured_mention.scope.name == "ISSUE" - def test_multiple_mentions_triggered_by( - self, test_router, get_mock_github_api_sync - ): - """Test that triggered_by is set correctly when multiple mentions exist.""" + def test_multiple_mentions_triggered_by(self, test_router, get_mock_github_api): handler_called = False captured_mention = None @@ -665,27 +612,22 @@ def deploy_handler(event, *args, **kwargs): delivery_id="456", ) - mock_gh = get_mock_github_api_sync({}) + mock_gh = get_mock_github_api({}) test_router.dispatch(event, mock_gh) assert handler_called assert captured_mention is not None - - # Check that we have multiple mentions assert len(captured_mention.comment.mentions) == 2 - - # Check triggered_by points to the "deploy" mention (second one) assert captured_mention.triggered_by.text == "deploy production" assert captured_mention.triggered_by.line_number == 2 - # Verify mention linking first_mention = captured_mention.comment.mentions[0] second_mention = captured_mention.comment.mentions[1] + assert first_mention.next_mention is second_mention assert second_mention.previous_mention is first_mention - def test_mention_without_pattern(self, test_router, get_mock_github_api_sync): - """Test handler with no specific pattern uses first mention as triggered_by.""" + def test_mention_without_pattern(self, test_router, get_mock_github_api): handler_called = False captured_mention = None @@ -711,21 +653,17 @@ def general_handler(event, *args, **kwargs): delivery_id="789", ) - mock_gh = get_mock_github_api_sync({}) + mock_gh = get_mock_github_api({}) test_router.dispatch(event, mock_gh) assert handler_called - assert captured_mention is not None - - # Should use first (and only) mention as triggered_by assert captured_mention.triggered_by.text == "can you help me?" assert captured_mention.triggered_by.username == "bot" @pytest.mark.asyncio async def test_async_mention_context_structure( - self, test_router, get_mock_github_api + self, test_router, aget_mock_github_api ): - """Test async handlers get the same updated MentionEvent structure.""" handler_called = False captured_mention = None @@ -751,22 +689,16 @@ async def async_handler(event, *args, **kwargs): delivery_id="999", ) - mock_gh = get_mock_github_api({}) + mock_gh = aget_mock_github_api({}) await test_router.adispatch(event, mock_gh) assert handler_called - assert captured_mention is not None - - # Verify structure is the same for async assert captured_mention.comment.body == "@bot async-test now" assert captured_mention.triggered_by.text == "async-test now" class TestFlexibleMentionTriggers: - """Test the extended mention decorator with username and pattern parameters.""" - - def test_pattern_parameter_string(self, test_router, get_mock_github_api_sync): - """Test pattern parameter with literal string matching.""" + def test_pattern_parameter_string(self, test_router, get_mock_github_api): handler_called = False captured_mention = None @@ -776,7 +708,6 @@ def deploy_handler(event, *args, **kwargs): handler_called = True captured_mention = kwargs.get("context") - # Should match event = sansio.Event( { "action": "created", @@ -790,7 +721,7 @@ def deploy_handler(event, *args, **kwargs): event="issue_comment", delivery_id="1", ) - mock_gh = get_mock_github_api_sync({}) + mock_gh = get_mock_github_api({}) test_router.dispatch(event, mock_gh) assert handler_called @@ -801,10 +732,10 @@ def deploy_handler(event, *args, **kwargs): handler_called = False event.data["comment"]["body"] = "@bot please deploy" test_router.dispatch(event, mock_gh) + assert not handler_called - def test_pattern_parameter_regex(self, test_router, get_mock_github_api_sync): - """Test pattern parameter with regex matching.""" + def test_pattern_parameter_regex(self, test_router, get_mock_github_api): handler_called = False captured_mention = None @@ -824,15 +755,14 @@ def deploy_env_handler(event, *args, **kwargs): event="issue_comment", delivery_id="1", ) - mock_gh = get_mock_github_api_sync({}) + mock_gh = get_mock_github_api({}) test_router.dispatch(event, mock_gh) assert handler_called assert captured_mention.triggered_by.match is not None assert captured_mention.triggered_by.match.group("env") == "staging" - def test_username_parameter_exact(self, test_router, get_mock_github_api_sync): - """Test username parameter with exact matching.""" + def test_username_parameter_exact(self, test_router, get_mock_github_api): handler_called = False @test_router.mention(username="deploy-bot") @@ -851,18 +781,19 @@ def deploy_bot_handler(event, *args, **kwargs): event="issue_comment", delivery_id="1", ) - mock_gh = get_mock_github_api_sync({}) + mock_gh = get_mock_github_api({}) test_router.dispatch(event, mock_gh) + assert handler_called # Should not match bot handler_called = False event.data["comment"]["body"] = "@bot run tests" test_router.dispatch(event, mock_gh) + assert not handler_called - def test_username_parameter_regex(self, test_router, get_mock_github_api_sync): - """Test username parameter with regex matching.""" + def test_username_parameter_regex(self, test_router, get_mock_github_api): handler_count = 0 @test_router.mention(username=re.compile(r".*-bot")) @@ -883,14 +814,13 @@ def any_bot_handler(event, *args, **kwargs): event="issue_comment", delivery_id="1", ) - mock_gh = get_mock_github_api_sync({}) + mock_gh = get_mock_github_api({}) test_router.dispatch(event, mock_gh) # Should be called twice (deploy-bot and test-bot) assert handler_count == 2 - def test_username_all_mentions(self, test_router, get_mock_github_api_sync): - """Test monitoring all mentions with username=.*""" + def test_username_all_mentions(self, test_router, get_mock_github_api): mentions_seen = [] @test_router.mention(username=re.compile(r".*")) @@ -911,13 +841,12 @@ def all_mentions_handler(event, *args, **kwargs): event="issue_comment", delivery_id="1", ) - mock_gh = get_mock_github_api_sync({}) + mock_gh = get_mock_github_api({}) test_router.dispatch(event, mock_gh) assert mentions_seen == ["alice", "bob", "charlie"] - def test_combined_filters(self, test_router, get_mock_github_api_sync): - """Test combining username, pattern, and scope filters.""" + def test_combined_filters(self, test_router, get_mock_github_api): calls = [] @test_router.mention( @@ -928,7 +857,6 @@ def test_combined_filters(self, test_router, get_mock_github_api_sync): def restricted_deploy(event, *args, **kwargs): calls.append(kwargs) - # Create fresh events for each test to avoid any caching issues def make_event(body): return sansio.Event( { @@ -943,20 +871,23 @@ def make_event(body): # All conditions met event1 = make_event("@deploy-bot deploy now") - mock_gh = get_mock_github_api_sync({}) + mock_gh = get_mock_github_api({}) test_router.dispatch(event1, mock_gh) + assert len(calls) == 1 # Wrong username pattern calls.clear() event2 = make_event("@bot deploy now") test_router.dispatch(event2, mock_gh) + assert len(calls) == 0 # Wrong pattern calls.clear() event3 = make_event("@deploy-bot help") test_router.dispatch(event3, mock_gh) + assert len(calls) == 0 # Wrong scope (issue instead of PR) @@ -975,12 +906,12 @@ def make_event(body): delivery_id="1", ) test_router.dispatch(event4, mock_gh) + assert len(calls) == 0 def test_multiple_decorators_different_patterns( - self, test_router, get_mock_github_api_sync + self, test_router, get_mock_github_api ): - """Test multiple decorators with different patterns on same function.""" patterns_matched = [] @test_router.mention(pattern=re.compile(r"deploy")) @@ -1000,13 +931,12 @@ def deploy_handler(event, *args, **kwargs): event="issue_comment", delivery_id="1", ) - mock_gh = get_mock_github_api_sync({}) + mock_gh = get_mock_github_api({}) test_router.dispatch(event, mock_gh) assert patterns_matched == ["ship"] - def test_question_pattern(self, test_router, get_mock_github_api_sync): - """Test natural language pattern matching for questions.""" + def test_question_pattern(self, test_router, get_mock_github_api): questions_received = [] @test_router.mention(pattern=re.compile(r".*\?$")) @@ -1027,7 +957,7 @@ def question_handler(event, *args, **kwargs): event="issue_comment", delivery_id="1", ) - mock_gh = get_mock_github_api_sync({}) + mock_gh = get_mock_github_api({}) test_router.dispatch(event, mock_gh) assert questions_received == ["what is the status?"] From cbc86570dcf5bccfd176eb4df43a184285a9f9bd Mon Sep 17 00:00:00 2001 From: Josh Date: Wed, 18 Jun 2025 23:00:37 -0500 Subject: [PATCH 17/28] Rename test fixtures for consistency between sync and async --- tests/conftest.py | 28 ++++++------ tests/test_mentions.py | 96 +++++++++--------------------------------- tests/test_models.py | 8 ++-- 3 files changed, 39 insertions(+), 93 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 5d1d3f2..4eb7eac 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -129,8 +129,8 @@ def repository_id(): @pytest.fixture -def get_mock_github_api(): - def _get_mock_github_api(return_data, installation_id=12345): +def aget_mock_github_api(): + def _aget_mock_github_api(return_data, installation_id=12345): mock_api = AsyncMock(spec=AsyncGitHubAPI) async def mock_getitem(*args, **kwargs): @@ -148,12 +148,12 @@ async def mock_getiter(*args, **kwargs): return mock_api - return _get_mock_github_api + return _aget_mock_github_api @pytest.fixture -def get_mock_github_api_sync(): - def _get_mock_github_api_sync(return_data, installation_id=12345): +def get_mock_github_api(): + def _get_mock_github_api(return_data, installation_id=12345): from django_github_app.github import SyncGitHubAPI mock_api = MagicMock(spec=SyncGitHubAPI) @@ -174,15 +174,15 @@ def mock_post(*args, **kwargs): return mock_api - return _get_mock_github_api_sync + return _get_mock_github_api @pytest.fixture -def installation(get_mock_github_api, baker): +def installation(aget_mock_github_api, baker): installation = baker.make( "django_github_app.Installation", installation_id=seq.next() ) - mock_github_api = get_mock_github_api( + mock_github_api = aget_mock_github_api( [ {"id": seq.next(), "node_id": "node1", "full_name": "owner/repo1"}, {"id": seq.next(), "node_id": "node2", "full_name": "owner/repo2"}, @@ -194,11 +194,11 @@ def installation(get_mock_github_api, baker): @pytest_asyncio.fixture -async def ainstallation(get_mock_github_api, baker): +async def ainstallation(aget_mock_github_api, baker): installation = await sync_to_async(baker.make)( "django_github_app.Installation", installation_id=seq.next() ) - mock_github_api = get_mock_github_api( + mock_github_api = aget_mock_github_api( [ {"id": seq.next(), "node_id": "node1", "full_name": "owner/repo1"}, {"id": seq.next(), "node_id": "node2", "full_name": "owner/repo2"}, @@ -210,14 +210,14 @@ async def ainstallation(get_mock_github_api, baker): @pytest.fixture -def repository(installation, get_mock_github_api, baker): +def repository(installation, aget_mock_github_api, baker): repository = baker.make( "django_github_app.Repository", repository_id=seq.next(), full_name="owner/repo", installation=installation, ) - mock_github_api = get_mock_github_api( + mock_github_api = aget_mock_github_api( [ { "number": 1, @@ -237,14 +237,14 @@ def repository(installation, get_mock_github_api, baker): @pytest_asyncio.fixture -async def arepository(ainstallation, get_mock_github_api, baker): +async def arepository(ainstallation, aget_mock_github_api, baker): repository = await sync_to_async(baker.make)( "django_github_app.Repository", repository_id=seq.next(), full_name="owner/repo", installation=ainstallation, ) - mock_github_api = get_mock_github_api( + mock_github_api = aget_mock_github_api( [ { "number": 1, diff --git a/tests/test_mentions.py b/tests/test_mentions.py index c281e23..d623eb8 100644 --- a/tests/test_mentions.py +++ b/tests/test_mentions.py @@ -8,13 +8,12 @@ from django_github_app.mentions import Comment from django_github_app.mentions import MentionScope +from django_github_app.mentions import check_pattern_match from django_github_app.mentions import parse_mentions_for_username @pytest.fixture def create_comment_event(): - """Fixture to create comment events for testing.""" - def _create(body: str) -> sansio.Event: return sansio.Event( {"comment": {"body": body}}, event="issue_comment", delivery_id="test" @@ -188,26 +187,21 @@ def test_special_character_command(self, create_comment_event): class TestGetEventScope: def test_from_event_for_various_events(self): - # Issue comment on actual issue event1 = sansio.Event({"issue": {}}, event="issue_comment", delivery_id="1") assert MentionScope.from_event(event1) == MentionScope.ISSUE - # PR review comment event2 = sansio.Event({}, event="pull_request_review_comment", delivery_id="2") assert MentionScope.from_event(event2) == MentionScope.PR - # Commit comment event3 = sansio.Event({}, event="commit_comment", delivery_id="3") assert MentionScope.from_event(event3) == MentionScope.COMMIT def test_issue_scope_on_issue_comment(self): - # Issue comment on an actual issue (no pull_request field) issue_event = sansio.Event( {"issue": {"title": "Bug report"}}, event="issue_comment", delivery_id="1" ) assert MentionScope.from_event(issue_event) == MentionScope.ISSUE - # Issue comment on a pull request (has pull_request field) pr_event = sansio.Event( {"issue": {"title": "PR title", "pull_request": {"url": "..."}}}, event="issue_comment", @@ -216,13 +210,11 @@ def test_issue_scope_on_issue_comment(self): assert MentionScope.from_event(pr_event) == MentionScope.PR def test_pr_scope_on_issue_comment(self): - # Issue comment on an actual issue (no pull_request field) issue_event = sansio.Event( {"issue": {"title": "Bug report"}}, event="issue_comment", delivery_id="1" ) assert MentionScope.from_event(issue_event) == MentionScope.ISSUE - # Issue comment on a pull request (has pull_request field) pr_event = sansio.Event( {"issue": {"title": "PR title", "pull_request": {"url": "..."}}}, event="issue_comment", @@ -231,42 +223,33 @@ def test_pr_scope_on_issue_comment(self): assert MentionScope.from_event(pr_event) == MentionScope.PR def test_pr_scope_allows_pr_specific_events(self): - # PR scope should allow pull_request_review_comment event1 = sansio.Event({}, event="pull_request_review_comment", delivery_id="1") assert MentionScope.from_event(event1) == MentionScope.PR - # PR scope should allow pull_request_review event2 = sansio.Event({}, event="pull_request_review", delivery_id="2") assert MentionScope.from_event(event2) == MentionScope.PR - # PR scope should not allow commit_comment event3 = sansio.Event({}, event="commit_comment", delivery_id="3") assert MentionScope.from_event(event3) == MentionScope.COMMIT def test_commit_scope_allows_commit_comment_only(self): - # Commit scope should allow commit_comment event1 = sansio.Event({}, event="commit_comment", delivery_id="1") assert MentionScope.from_event(event1) == MentionScope.COMMIT - # Commit scope should not allow issue_comment event2 = sansio.Event({"issue": {}}, event="issue_comment", delivery_id="2") assert MentionScope.from_event(event2) == MentionScope.ISSUE - # Commit scope should not allow PR events event3 = sansio.Event({}, event="pull_request_review_comment", delivery_id="3") assert MentionScope.from_event(event3) == MentionScope.PR def test_different_event_types_have_correct_scope(self): - # pull_request_review_comment should be PR scope event1 = sansio.Event({}, event="pull_request_review_comment", delivery_id="1") assert MentionScope.from_event(event1) == MentionScope.PR - # commit_comment should be COMMIT scope event2 = sansio.Event({}, event="commit_comment", delivery_id="2") assert MentionScope.from_event(event2) == MentionScope.COMMIT def test_pull_request_field_none_treated_as_issue(self): - # If pull_request field exists but is None, treat as issue event = sansio.Event( {"issue": {"title": "Issue", "pull_request": None}}, event="issue_comment", @@ -275,19 +258,16 @@ def test_pull_request_field_none_treated_as_issue(self): assert MentionScope.from_event(event) == MentionScope.ISSUE def test_missing_issue_data(self): - # If issue data is missing entirely, defaults to ISSUE scope for issue_comment event = sansio.Event({}, event="issue_comment", delivery_id="1") assert MentionScope.from_event(event) == MentionScope.ISSUE def test_unknown_event_returns_none(self): - # Unknown event types should return None event = sansio.Event({}, event="unknown_event", delivery_id="1") assert MentionScope.from_event(event) is None class TestComment: def test_from_event_issue_comment(self): - """Test Comment.from_event() with issue_comment event.""" event = sansio.Event( { "comment": { @@ -311,7 +291,6 @@ def test_from_event_issue_comment(self): assert comment.line_count == 1 def test_from_event_pull_request_review_comment(self): - """Test Comment.from_event() with pull_request_review_comment event.""" event = sansio.Event( { "comment": { @@ -333,7 +312,6 @@ def test_from_event_pull_request_review_comment(self): assert comment.line_count == 3 def test_from_event_pull_request_review(self): - """Test Comment.from_event() with pull_request_review event.""" event = sansio.Event( { "review": { @@ -356,7 +334,6 @@ def test_from_event_pull_request_review(self): ) def test_from_event_commit_comment(self): - """Test Comment.from_event() with commit_comment event.""" event = sansio.Event( { "comment": { @@ -380,7 +357,6 @@ def test_from_event_commit_comment(self): ) def test_from_event_missing_fields(self): - """Test Comment.from_event() with missing optional fields.""" event = sansio.Event( { "comment": { @@ -396,13 +372,12 @@ def test_from_event_missing_fields(self): comment = Comment.from_event(event) assert comment.body == "Minimal comment" - assert comment.author == "fallback-user" # Falls back to sender + assert comment.author == "fallback-user" assert comment.url == "" # created_at should be roughly now assert (timezone.now() - comment.created_at).total_seconds() < 5 def test_from_event_invalid_event_type(self): - """Test Comment.from_event() with unsupported event type.""" event = sansio.Event( {"some_data": "value"}, event="push", @@ -414,32 +389,26 @@ def test_from_event_invalid_event_type(self): ): Comment.from_event(event) - def test_line_count_property(self): - """Test the line_count property with various comment bodies.""" - # Single line + @pytest.mark.parametrize( + "body,line_count", + [ + ("Single line", 1), + ("Line 1\nLine 2\nLine 3", 3), + ("Line 1\n\nLine 3", 3), + ("", 0), + ], + ) + def test_line_count_property(self, body, line_count): comment = Comment( - body="Single line", + body=body, author="user", created_at=timezone.now(), url="", mentions=[], ) - assert comment.line_count == 1 - - # Multiple lines - comment.body = "Line 1\nLine 2\nLine 3" - assert comment.line_count == 3 - - # Empty lines count - comment.body = "Line 1\n\nLine 3" - assert comment.line_count == 3 - - # Empty body - comment.body = "" - assert comment.line_count == 0 + assert comment.line_count == line_count def test_from_event_timezone_handling(self): - """Test timezone handling in created_at parsing.""" event = sansio.Event( { "comment": { @@ -462,17 +431,12 @@ def test_from_event_timezone_handling(self): class TestPatternMatching: def test_check_pattern_match_none(self): - """Test check_pattern_match with None pattern.""" - from django_github_app.mentions import check_pattern_match - match = check_pattern_match("any text", None) + assert match is not None assert match.group(0) == "any text" def test_check_pattern_match_literal_string(self): - """Test check_pattern_match with literal string pattern.""" - from django_github_app.mentions import check_pattern_match - # Matching case match = check_pattern_match("deploy production", "deploy") assert match is not None @@ -491,9 +455,6 @@ def test_check_pattern_match_literal_string(self): assert match is None def test_check_pattern_match_regex(self): - """Test check_pattern_match with regex patterns.""" - from django_github_app.mentions import check_pattern_match - # Simple regex match = check_pattern_match("deploy prod", re.compile(r"deploy (prod|staging)")) assert match is not None @@ -516,9 +477,6 @@ def test_check_pattern_match_regex(self): assert match is None def test_check_pattern_match_invalid_regex(self): - """Test check_pattern_match with invalid regex falls back to literal.""" - from django_github_app.mentions import check_pattern_match - # Invalid regex should be treated as literal match = check_pattern_match("test [invalid", "[invalid") assert match is None # Doesn't start with [invalid @@ -527,9 +485,6 @@ def test_check_pattern_match_invalid_regex(self): assert match is not None # Starts with literal [invalid def test_check_pattern_match_flag_preservation(self): - """Test that regex flags are preserved when using compiled patterns.""" - from django_github_app.mentions import check_pattern_match - # Case-sensitive pattern pattern_cs = re.compile(r"DEPLOY", re.MULTILINE) match = check_pattern_match("deploy", pattern_cs) @@ -538,17 +493,16 @@ def test_check_pattern_match_flag_preservation(self): # Case-insensitive pattern pattern_ci = re.compile(r"DEPLOY", re.IGNORECASE) match = check_pattern_match("deploy", pattern_ci) + assert match is not None # Should match # Multiline pattern pattern_ml = re.compile(r"^prod$", re.MULTILINE) match = check_pattern_match("staging\nprod\ndev", pattern_ml) + assert match is None # Pattern expects exact match from start def test_parse_mentions_for_username_default(self): - """Test parse_mentions_for_username with default username.""" - from django_github_app.mentions import parse_mentions_for_username - event = sansio.Event( {"comment": {"body": "@bot help @otherbot test"}}, event="issue_comment", @@ -556,14 +510,12 @@ def test_parse_mentions_for_username_default(self): ) mentions = parse_mentions_for_username(event, None) # Uses default "bot" + assert len(mentions) == 1 assert mentions[0].username == "bot" assert mentions[0].text == "help @otherbot test" def test_parse_mentions_for_username_specific(self): - """Test parse_mentions_for_username with specific username.""" - from django_github_app.mentions import parse_mentions_for_username - event = sansio.Event( {"comment": {"body": "@bot help @deploy-bot test @test-bot check"}}, event="issue_comment", @@ -571,14 +523,12 @@ def test_parse_mentions_for_username_specific(self): ) mentions = parse_mentions_for_username(event, "deploy-bot") + assert len(mentions) == 1 assert mentions[0].username == "deploy-bot" assert mentions[0].text == "test @test-bot check" def test_parse_mentions_for_username_regex(self): - """Test parse_mentions_for_username with regex pattern.""" - from django_github_app.mentions import parse_mentions_for_username - event = sansio.Event( { "comment": { @@ -589,30 +539,26 @@ def test_parse_mentions_for_username_regex(self): delivery_id="test", ) - # Match any username ending in -bot mentions = parse_mentions_for_username(event, re.compile(r".*-bot")) + assert len(mentions) == 2 assert mentions[0].username == "deploy-bot" assert mentions[0].text == "test" assert mentions[1].username == "test-bot" assert mentions[1].text == "check @user ignore" - # Verify mention linking assert mentions[0].next_mention is mentions[1] assert mentions[1].previous_mention is mentions[0] def test_parse_mentions_for_username_all(self): - """Test parse_mentions_for_username matching all mentions.""" - from django_github_app.mentions import parse_mentions_for_username - event = sansio.Event( {"comment": {"body": "@alice review @bob help @charlie test"}}, event="issue_comment", delivery_id="test", ) - # Match all mentions with .* mentions = parse_mentions_for_username(event, re.compile(r".*")) + assert len(mentions) == 3 assert mentions[0].username == "alice" assert mentions[0].text == "review" diff --git a/tests/test_models.py b/tests/test_models.py index bc1d5c6..a562931 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -272,10 +272,10 @@ async def test_arefresh_from_gh( account_type, private_key, ainstallation, - get_mock_github_api, + aget_mock_github_api, override_app_settings, ): - mock_github_api = get_mock_github_api({"foo": "bar"}) + mock_github_api = aget_mock_github_api({"foo": "bar"}) ainstallation.get_gh_client = MagicMock(return_value=mock_github_api) with override_app_settings(PRIVATE_KEY=private_key): @@ -289,10 +289,10 @@ def test_refresh_from_gh( account_type, private_key, installation, - get_mock_github_api, + aget_mock_github_api, override_app_settings, ): - mock_github_api = get_mock_github_api({"foo": "bar"}) + mock_github_api = aget_mock_github_api({"foo": "bar"}) installation.get_gh_client = MagicMock(return_value=mock_github_api) with override_app_settings(PRIVATE_KEY=private_key): From 5eeac1f13f63f6810d401736433b01107d03e6de Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 19 Jun 2025 04:01:50 +0000 Subject: [PATCH 18/28] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/django_github_app/mentions.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/django_github_app/mentions.py b/src/django_github_app/mentions.py index 027cdad..3281060 100644 --- a/src/django_github_app/mentions.py +++ b/src/django_github_app/mentions.py @@ -290,4 +290,3 @@ def from_event( triggered_by=mention, scope=event_scope, ) - From d1292024764225b144e4dfef9ae6e2d60cbe77fc Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 19 Jun 2025 11:51:36 -0500 Subject: [PATCH 19/28] Refactor mention parsing for clarity and maintainability --- src/django_github_app/mentions.py | 238 +++++++++++++++------------- src/django_github_app/routing.py | 10 +- tests/test_mentions.py | 247 +++++++++++++++++++++++------- tests/test_routing.py | 36 ++--- 4 files changed, 345 insertions(+), 186 deletions(-) diff --git a/src/django_github_app/mentions.py b/src/django_github_app/mentions.py index 3281060..80e10f8 100644 --- a/src/django_github_app/mentions.py +++ b/src/django_github_app/mentions.py @@ -48,7 +48,6 @@ def all_events(cls) -> list[EventAction]: @classmethod def from_event(cls, event: sansio.Event) -> MentionScope | None: - """Determine the scope of a GitHub event based on its type and context.""" if event.event == "issue_comment": issue = event.data.get("issue", {}) is_pull_request = ( @@ -65,128 +64,134 @@ def from_event(cls, event: sansio.Event) -> MentionScope | None: @dataclass -class Mention: +class RawMention: + match: re.Match[str] username: str - text: str position: int - line_number: int - line_text: str - match: re.Match[str] | None = None - previous_mention: Mention | None = None - next_mention: Mention | None = None + end: int -def check_pattern_match( - text: str, pattern: str | re.Pattern[str] | None -) -> re.Match[str] | None: - """Check if text matches the given pattern (string or regex). +CODE_BLOCK_PATTERN = re.compile(r"```[\s\S]*?```", re.MULTILINE) +INLINE_CODE_PATTERN = re.compile(r"`[^`]+`") +BLOCKQUOTE_PATTERN = re.compile(r"^\s*>.*$", re.MULTILINE) - Returns Match object if pattern matches, None otherwise. - If pattern is None, returns a dummy match object. - """ - if pattern is None: - return re.match(r"(.*)", text, re.IGNORECASE | re.DOTALL) - # Check if it's a compiled regex pattern - if isinstance(pattern, re.Pattern): - # Use the pattern directly, preserving its flags - return pattern.match(text) +# GitHub username rules: +# - 1-39 characters long +# - Can only contain alphanumeric characters or hyphens +# - Cannot start or end with a hyphen +# - Cannot have multiple consecutive hyphens +GITHUB_MENTION_PATTERN = re.compile( + r"(?:^|(?<=\s))@([a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38})", + re.MULTILINE | re.IGNORECASE, +) - # For strings, do exact match (case-insensitive) - # Escape the string to treat it literally - escaped_pattern = re.escape(pattern) - return re.match(escaped_pattern, text, re.IGNORECASE) +def extract_all_mentions(text: str) -> list[RawMention]: + # replace all code blocks, inline code, and blockquotes with spaces + # this preserves linenos and postitions while not being able to + # match against anything in them + processed_text = CODE_BLOCK_PATTERN.sub(lambda m: " " * len(m.group(0)), text) + processed_text = INLINE_CODE_PATTERN.sub( + lambda m: " " * len(m.group(0)), processed_text + ) + processed_text = BLOCKQUOTE_PATTERN.sub( + lambda m: " " * len(m.group(0)), processed_text + ) + return [ + RawMention( + match=match, + username=match.group(1), + position=match.start(), + end=match.end(), + ) + for match in GITHUB_MENTION_PATTERN.finditer(processed_text) + ] -CODE_BLOCK_PATTERN = re.compile(r"```[\s\S]*?```", re.MULTILINE) -INLINE_CODE_PATTERN = re.compile(r"`[^`]+`") -QUOTE_PATTERN = re.compile(r"^\s*>.*$", re.MULTILINE) +class LineInfo(NamedTuple): + lineno: int + text: str -def parse_mentions_for_username( - event: sansio.Event, username_pattern: str | re.Pattern[str] | None = None -) -> list[Mention]: - comment = event.data.get("comment", {}) - if comment is None: - comment = {} - body = comment.get("body", "") + @classmethod + def for_mention_in_comment(cls, comment: str, mention_position: int): + lines = comment.splitlines() + text_before = comment[:mention_position] + line_number = text_before.count("\n") + 1 - if not body: - return [] + line_index = line_number - 1 + line_text = lines[line_index] if line_index < len(lines) else "" - # If no pattern specified, use bot username (TODO: get from settings) - if username_pattern is None: - username_pattern = "bot" # Placeholder + return cls(lineno=line_number, text=line_text) - # Handle regex patterns vs literal strings - if isinstance(username_pattern, re.Pattern): - # Use the pattern string directly, preserving any flags - username_regex = username_pattern.pattern - # Extract flags from the compiled pattern - flags = username_pattern.flags | re.MULTILINE | re.IGNORECASE - else: - # For strings, escape them to be treated literally - username_regex = re.escape(username_pattern) - flags = re.MULTILINE | re.IGNORECASE - original_body = body - original_lines = original_body.splitlines() +def extract_mention_text( + body: str, current_index: int, all_mentions: list[RawMention], mention_end: int +) -> str: + text_start = mention_end - processed_text = CODE_BLOCK_PATTERN.sub(lambda m: " " * len(m.group(0)), body) - processed_text = INLINE_CODE_PATTERN.sub( - lambda m: " " * len(m.group(0)), processed_text - ) - processed_text = QUOTE_PATTERN.sub(lambda m: " " * len(m.group(0)), processed_text) + # Find next @mention (any mention, not just matched ones) to know where this text ends + next_mention_index = None + for j in range(current_index + 1, len(all_mentions)): + next_mention_index = j + break - # Use \S+ to match non-whitespace characters for username - # Special handling for patterns that could match too broadly - if ".*" in username_regex: - # Replace .* with a more specific pattern that won't match spaces or @ - username_regex = username_regex.replace(".*", r"[^@\s]*") + if next_mention_index is not None: + text_end = all_mentions[next_mention_index].position + else: + text_end = len(body) - mention_pattern = re.compile( - rf"(?:^|(?<=\s))@({username_regex})(?:\s|$|(?=[^\w\-]))", - flags, - ) + return body[text_start:text_end].strip() - mentions: list[Mention] = [] - for match in mention_pattern.finditer(processed_text): - position = match.start() # Position of @ - username = match.group(1) # Captured username +@dataclass +class ParsedMention: + username: str + text: str + position: int + line_info: LineInfo + match: re.Match[str] | None = None + previous_mention: ParsedMention | None = None + next_mention: ParsedMention | None = None - text_before = original_body[:position] - line_number = text_before.count("\n") + 1 - line_index = line_number - 1 - line_text = ( - original_lines[line_index] if line_index < len(original_lines) else "" - ) +def extract_mentions_from_event( + event: sansio.Event, username_pattern: str | re.Pattern[str] | None = None +) -> list[ParsedMention]: + comment_data = event.data.get("comment", {}) + if comment_data is None: + comment_data = {} + comment = comment_data.get("body", "") - text_start = match.end() + if not comment: + return [] - # Find next @mention to know where this text ends - next_match = mention_pattern.search(processed_text, match.end()) - if next_match: - text_end = next_match.start() - else: - text_end = len(original_body) - - text = original_body[text_start:text_end].strip() - - mention = Mention( - username=username, - text=text, - position=position, - line_number=line_number, - line_text=line_text, - match=None, - previous_mention=None, - next_mention=None, - ) + # If no pattern specified, use bot username (TODO: get from settings) + if username_pattern is None: + username_pattern = "bot" # Placeholder - mentions.append(mention) + mentions: list[ParsedMention] = [] + potential_mentions = extract_all_mentions(comment) + for i, raw_mention in enumerate(potential_mentions): + if not matches_pattern(raw_mention.username, username_pattern): + continue + + text = extract_mention_text(comment, i, potential_mentions, raw_mention.end) + line_info = LineInfo.for_mention_in_comment(comment, raw_mention.position) + + mentions.append( + ParsedMention( + username=raw_mention.username, + text=text, + position=raw_mention.position, + line_info=line_info, + match=None, + previous_mention=None, + next_mention=None, + ) + ) + # link mentions for i, mention in enumerate(mentions): if i > 0: mention.previous_mention = mentions[i - 1] @@ -202,11 +207,10 @@ class Comment: author: str created_at: datetime url: str - mentions: list[Mention] + mentions: list[ParsedMention] @property def line_count(self) -> int: - """Number of lines in the comment.""" if not self.body: return 0 return len(self.body.splitlines()) @@ -224,8 +228,7 @@ def from_event(cls, event: sansio.Event) -> Comment: if not comment_data: raise ValueError(f"Cannot extract comment from event type: {event.event}") - created_at_str = comment_data.get("created_at", "") - if created_at_str: + if created_at_str := comment_data.get("created_at", ""): # GitHub timestamps are in ISO format: 2024-01-01T12:00:00Z created_at_aware = datetime.fromisoformat( created_at_str.replace("Z", "+00:00") @@ -253,9 +256,9 @@ def from_event(cls, event: sansio.Event) -> Comment: @dataclass -class MentionEvent: +class Mention: comment: Comment - triggered_by: Mention + mention: ParsedMention scope: MentionScope | None @classmethod @@ -271,7 +274,7 @@ def from_event( if scope is not None and event_scope != scope: return - mentions = parse_mentions_for_username(event, username) + mentions = extract_mentions_from_event(event, username) if not mentions: return @@ -280,13 +283,36 @@ def from_event( for mention in mentions: if pattern is not None: - match = check_pattern_match(mention.text, pattern) + match = get_match(mention.text, pattern) if not match: continue mention.match = match yield cls( comment=comment, - triggered_by=mention, + mention=mention, scope=event_scope, ) + + +def matches_pattern(text: str, pattern: str | re.Pattern[str] | None) -> bool: + match pattern: + case None: + return True + case re.Pattern(): + return pattern.fullmatch(text) is not None + case str(): + return text.strip().lower() == pattern.strip().lower() + + +def get_match(text: str, pattern: str | re.Pattern[str] | None) -> re.Match[str] | None: + match pattern: + case None: + return re.match(r"(.*)", text, re.IGNORECASE | re.DOTALL) + case re.Pattern(): + # Use the pattern directly, preserving its flags + return pattern.match(text) + case str(): + # For strings, do exact match (case-insensitive) + # Escape the string to treat it literally + return re.match(re.escape(pattern), text, re.IGNORECASE) diff --git a/src/django_github_app/routing.py b/src/django_github_app/routing.py index 3c31e03..dee7df9 100644 --- a/src/django_github_app/routing.py +++ b/src/django_github_app/routing.py @@ -17,7 +17,7 @@ from ._typing import override from .github import AsyncGitHubAPI from .github import SyncGitHubAPI -from .mentions import MentionEvent +from .mentions import Mention from .mentions import MentionScope AsyncCallback = Callable[..., Awaitable[None]] @@ -83,19 +83,19 @@ def decorator(func: CB) -> CB: async def async_wrapper( event: sansio.Event, gh: AsyncGitHubAPI, *args: Any, **kwargs: Any ) -> None: - for context in MentionEvent.from_event( + for mention in Mention.from_event( event, username=username, pattern=pattern, scope=scope ): - await func(event, gh, *args, context=context, **kwargs) # type: ignore[func-returns-value] + await func(event, gh, *args, context=mention, **kwargs) # type: ignore[func-returns-value] @wraps(func) def sync_wrapper( event: sansio.Event, gh: SyncGitHubAPI, *args: Any, **kwargs: Any ) -> None: - for context in MentionEvent.from_event( + for mention in Mention.from_event( event, username=username, pattern=pattern, scope=scope ): - func(event, gh, *args, context=context, **kwargs) + func(event, gh, *args, context=mention, **kwargs) wrapper: MentionHandler if iscoroutinefunction(func): diff --git a/tests/test_mentions.py b/tests/test_mentions.py index d623eb8..b44c400 100644 --- a/tests/test_mentions.py +++ b/tests/test_mentions.py @@ -1,6 +1,7 @@ from __future__ import annotations import re +import time import pytest from django.utils import timezone @@ -8,8 +9,8 @@ from django_github_app.mentions import Comment from django_github_app.mentions import MentionScope -from django_github_app.mentions import check_pattern_match -from django_github_app.mentions import parse_mentions_for_username +from django_github_app.mentions import get_match +from django_github_app.mentions import extract_mentions_from_event @pytest.fixture @@ -25,17 +26,17 @@ def _create(body: str) -> sansio.Event: class TestParseMentions: def test_simple_mention_with_command(self, create_comment_event): event = create_comment_event("@mybot help") - mentions = parse_mentions_for_username(event, "mybot") + mentions = extract_mentions_from_event(event, "mybot") assert len(mentions) == 1 assert mentions[0].username == "mybot" assert mentions[0].text == "help" assert mentions[0].position == 0 - assert mentions[0].line_number == 1 + assert mentions[0].line_info.lineno == 1 def test_mention_without_command(self, create_comment_event): event = create_comment_event("@mybot") - mentions = parse_mentions_for_username(event, "mybot") + mentions = extract_mentions_from_event(event, "mybot") assert len(mentions) == 1 assert mentions[0].username == "mybot" @@ -43,7 +44,7 @@ def test_mention_without_command(self, create_comment_event): def test_case_insensitive_matching(self, create_comment_event): event = create_comment_event("@MyBot help") - mentions = parse_mentions_for_username(event, "mybot") + mentions = extract_mentions_from_event(event, "mybot") assert len(mentions) == 1 assert mentions[0].username == "MyBot" # Username is preserved as found @@ -51,7 +52,7 @@ def test_case_insensitive_matching(self, create_comment_event): def test_command_case_normalization(self, create_comment_event): event = create_comment_event("@mybot HELP") - mentions = parse_mentions_for_username(event, "mybot") + mentions = extract_mentions_from_event(event, "mybot") assert len(mentions) == 1 # Command case is preserved in text, normalization happens elsewhere @@ -59,7 +60,7 @@ def test_command_case_normalization(self, create_comment_event): def test_multiple_mentions(self, create_comment_event): event = create_comment_event("@mybot help and then @mybot deploy") - mentions = parse_mentions_for_username(event, "mybot") + mentions = extract_mentions_from_event(event, "mybot") assert len(mentions) == 2 assert mentions[0].text == "help and then" @@ -67,10 +68,10 @@ def test_multiple_mentions(self, create_comment_event): def test_ignore_other_mentions(self, create_comment_event): event = create_comment_event("@otheruser help @mybot deploy @someone else") - mentions = parse_mentions_for_username(event, "mybot") + mentions = extract_mentions_from_event(event, "mybot") assert len(mentions) == 1 - assert mentions[0].text == "deploy @someone else" + assert mentions[0].text == "deploy" def test_mention_in_code_block(self, create_comment_event): text = """ @@ -81,7 +82,7 @@ def test_mention_in_code_block(self, create_comment_event): @mybot deploy """ event = create_comment_event(text) - mentions = parse_mentions_for_username(event, "mybot") + mentions = extract_mentions_from_event(event, "mybot") assert len(mentions) == 1 assert mentions[0].text == "deploy" @@ -90,7 +91,7 @@ def test_mention_in_inline_code(self, create_comment_event): event = create_comment_event( "Use `@mybot help` for help, or just @mybot deploy" ) - mentions = parse_mentions_for_username(event, "mybot") + mentions = extract_mentions_from_event(event, "mybot") assert len(mentions) == 1 assert mentions[0].text == "deploy" @@ -101,85 +102,84 @@ def test_mention_in_quote(self, create_comment_event): @mybot deploy """ event = create_comment_event(text) - mentions = parse_mentions_for_username(event, "mybot") + mentions = extract_mentions_from_event(event, "mybot") assert len(mentions) == 1 assert mentions[0].text == "deploy" def test_empty_text(self, create_comment_event): event = create_comment_event("") - mentions = parse_mentions_for_username(event, "mybot") + mentions = extract_mentions_from_event(event, "mybot") assert mentions == [] def test_none_text(self, create_comment_event): # Create an event with no comment body event = sansio.Event({}, event="issue_comment", delivery_id="test") - mentions = parse_mentions_for_username(event, "mybot") + mentions = extract_mentions_from_event(event, "mybot") assert mentions == [] def test_mention_at_start_of_line(self, create_comment_event): event = create_comment_event("@mybot help") - mentions = parse_mentions_for_username(event, "mybot") + mentions = extract_mentions_from_event(event, "mybot") assert len(mentions) == 1 assert mentions[0].text == "help" def test_mention_in_middle_of_text(self, create_comment_event): event = create_comment_event("Hey @mybot help me") - mentions = parse_mentions_for_username(event, "mybot") + mentions = extract_mentions_from_event(event, "mybot") assert len(mentions) == 1 assert mentions[0].text == "help me" def test_mention_with_punctuation_after(self, create_comment_event): event = create_comment_event("@mybot help!") - mentions = parse_mentions_for_username(event, "mybot") + mentions = extract_mentions_from_event(event, "mybot") assert len(mentions) == 1 assert mentions[0].text == "help!" def test_hyphenated_username(self, create_comment_event): event = create_comment_event("@my-bot help") - mentions = parse_mentions_for_username(event, "my-bot") + mentions = extract_mentions_from_event(event, "my-bot") assert len(mentions) == 1 assert mentions[0].username == "my-bot" assert mentions[0].text == "help" def test_underscore_username(self, create_comment_event): + # GitHub usernames don't support underscores event = create_comment_event("@my_bot help") - mentions = parse_mentions_for_username(event, "my_bot") + mentions = extract_mentions_from_event(event, "my_bot") - assert len(mentions) == 1 - assert mentions[0].username == "my_bot" - assert mentions[0].text == "help" + assert len(mentions) == 0 # Should not match invalid username def test_no_space_after_mention(self, create_comment_event): event = create_comment_event("@mybot, please help") - mentions = parse_mentions_for_username(event, "mybot") + mentions = extract_mentions_from_event(event, "mybot") assert len(mentions) == 1 assert mentions[0].text == ", please help" def test_multiple_spaces_before_command(self, create_comment_event): event = create_comment_event("@mybot help") - mentions = parse_mentions_for_username(event, "mybot") + mentions = extract_mentions_from_event(event, "mybot") assert len(mentions) == 1 assert mentions[0].text == "help" # Whitespace is stripped def test_hyphenated_command(self, create_comment_event): event = create_comment_event("@mybot async-test") - mentions = parse_mentions_for_username(event, "mybot") + mentions = extract_mentions_from_event(event, "mybot") assert len(mentions) == 1 assert mentions[0].text == "async-test" def test_special_character_command(self, create_comment_event): event = create_comment_event("@mybot ?") - mentions = parse_mentions_for_username(event, "mybot") + mentions = extract_mentions_from_event(event, "mybot") assert len(mentions) == 1 assert mentions[0].text == "?" @@ -430,105 +430,105 @@ def test_from_event_timezone_handling(self): class TestPatternMatching: - def test_check_pattern_match_none(self): - match = check_pattern_match("any text", None) + def test_get_match_none(self): + match = get_match("any text", None) assert match is not None assert match.group(0) == "any text" - def test_check_pattern_match_literal_string(self): + def test_get_match_literal_string(self): # Matching case - match = check_pattern_match("deploy production", "deploy") + match = get_match("deploy production", "deploy") assert match is not None assert match.group(0) == "deploy" # Case insensitive - match = check_pattern_match("DEPLOY production", "deploy") + match = get_match("DEPLOY production", "deploy") assert match is not None # No match - match = check_pattern_match("help me", "deploy") + match = get_match("help me", "deploy") assert match is None # Must start with pattern - match = check_pattern_match("please deploy", "deploy") + match = get_match("please deploy", "deploy") assert match is None - def test_check_pattern_match_regex(self): + def test_get_match_regex(self): # Simple regex - match = check_pattern_match("deploy prod", re.compile(r"deploy (prod|staging)")) + match = get_match("deploy prod", re.compile(r"deploy (prod|staging)")) assert match is not None assert match.group(0) == "deploy prod" assert match.group(1) == "prod" # Named groups - match = check_pattern_match( + match = get_match( "deploy-prod", re.compile(r"deploy-(?Pprod|staging|dev)") ) assert match is not None assert match.group("env") == "prod" # Question mark pattern - match = check_pattern_match("can you help?", re.compile(r".*\?$")) + match = get_match("can you help?", re.compile(r".*\?$")) assert match is not None # No match - match = check_pattern_match("deploy test", re.compile(r"deploy (prod|staging)")) + match = get_match("deploy test", re.compile(r"deploy (prod|staging)")) assert match is None - def test_check_pattern_match_invalid_regex(self): + def test_get_match_invalid_regex(self): # Invalid regex should be treated as literal - match = check_pattern_match("test [invalid", "[invalid") + match = get_match("test [invalid", "[invalid") assert match is None # Doesn't start with [invalid - match = check_pattern_match("[invalid regex", "[invalid") + match = get_match("[invalid regex", "[invalid") assert match is not None # Starts with literal [invalid - def test_check_pattern_match_flag_preservation(self): + def test_get_match_flag_preservation(self): # Case-sensitive pattern pattern_cs = re.compile(r"DEPLOY", re.MULTILINE) - match = check_pattern_match("deploy", pattern_cs) + match = get_match("deploy", pattern_cs) assert match is None # Should not match due to case sensitivity # Case-insensitive pattern pattern_ci = re.compile(r"DEPLOY", re.IGNORECASE) - match = check_pattern_match("deploy", pattern_ci) + match = get_match("deploy", pattern_ci) assert match is not None # Should match # Multiline pattern pattern_ml = re.compile(r"^prod$", re.MULTILINE) - match = check_pattern_match("staging\nprod\ndev", pattern_ml) + match = get_match("staging\nprod\ndev", pattern_ml) assert match is None # Pattern expects exact match from start - def test_parse_mentions_for_username_default(self): + def test_extract_mentions_from_event_default(self): event = sansio.Event( {"comment": {"body": "@bot help @otherbot test"}}, event="issue_comment", delivery_id="test", ) - mentions = parse_mentions_for_username(event, None) # Uses default "bot" + mentions = extract_mentions_from_event(event, None) # Uses default "bot" assert len(mentions) == 1 assert mentions[0].username == "bot" - assert mentions[0].text == "help @otherbot test" + assert mentions[0].text == "help" - def test_parse_mentions_for_username_specific(self): + def test_extract_mentions_from_event_specific(self): event = sansio.Event( {"comment": {"body": "@bot help @deploy-bot test @test-bot check"}}, event="issue_comment", delivery_id="test", ) - mentions = parse_mentions_for_username(event, "deploy-bot") + mentions = extract_mentions_from_event(event, "deploy-bot") assert len(mentions) == 1 assert mentions[0].username == "deploy-bot" - assert mentions[0].text == "test @test-bot check" + assert mentions[0].text == "test" - def test_parse_mentions_for_username_regex(self): + def test_extract_mentions_from_event_regex(self): event = sansio.Event( { "comment": { @@ -539,25 +539,25 @@ def test_parse_mentions_for_username_regex(self): delivery_id="test", ) - mentions = parse_mentions_for_username(event, re.compile(r".*-bot")) + mentions = extract_mentions_from_event(event, re.compile(r".*-bot")) assert len(mentions) == 2 assert mentions[0].username == "deploy-bot" assert mentions[0].text == "test" assert mentions[1].username == "test-bot" - assert mentions[1].text == "check @user ignore" + assert mentions[1].text == "check" assert mentions[0].next_mention is mentions[1] assert mentions[1].previous_mention is mentions[0] - def test_parse_mentions_for_username_all(self): + def test_extract_mentions_from_event_all(self): event = sansio.Event( {"comment": {"body": "@alice review @bob help @charlie test"}}, event="issue_comment", delivery_id="test", ) - mentions = parse_mentions_for_username(event, re.compile(r".*")) + mentions = extract_mentions_from_event(event, re.compile(r".*")) assert len(mentions) == 3 assert mentions[0].username == "alice" @@ -566,3 +566,136 @@ def test_parse_mentions_for_username_all(self): assert mentions[1].text == "help" assert mentions[2].username == "charlie" assert mentions[2].text == "test" + + +class TestReDoSProtection: + """Test that the ReDoS vulnerability has been fixed.""" + + def test_redos_vulnerability_fixed(self, create_comment_event): + """Test that malicious input doesn't cause catastrophic backtracking.""" + # Create a malicious comment that would cause ReDoS with the old implementation + # Pattern: (bot|ai|assistant)+ matching "botbotbot...x" + malicious_username = "bot" * 20 + "x" + event = create_comment_event(f"@{malicious_username} hello") + + # This pattern would cause catastrophic backtracking in the old implementation + pattern = re.compile(r"(bot|ai|assistant)+") + + # Measure execution time + start_time = time.time() + mentions = extract_mentions_from_event(event, pattern) + execution_time = time.time() - start_time + + # Should complete quickly (under 0.1 seconds) - old implementation would take seconds/minutes + assert execution_time < 0.1 + # The username gets truncated at 39 chars, and the 'x' is left out + # So it will match the pattern, but the important thing is it completes quickly + assert len(mentions) == 1 + assert ( + mentions[0].username == "botbotbotbotbotbotbotbotbotbotbotbotbot" + ) # 39 chars + + def test_nested_quantifier_pattern(self, create_comment_event): + """Test patterns with nested quantifiers don't cause issues.""" + event = create_comment_event("@deploy-bot-bot-bot test command") + + # This type of pattern could cause issues: (word)+ + pattern = re.compile(r"(deploy|bot)+") + + start_time = time.time() + mentions = extract_mentions_from_event(event, pattern) + execution_time = time.time() - start_time + + assert execution_time < 0.1 + # Username contains hyphens, so it won't match this pattern + assert len(mentions) == 0 + + def test_alternation_with_quantifier(self, create_comment_event): + """Test alternation patterns with quantifiers.""" + event = create_comment_event("@mybot123bot456bot789 deploy") + + # Pattern like (a|b)* that could be dangerous + pattern = re.compile(r"(my|bot|[0-9])+") + + start_time = time.time() + mentions = extract_mentions_from_event(event, pattern) + execution_time = time.time() - start_time + + assert execution_time < 0.1 + # Should match safely + assert len(mentions) == 1 + assert mentions[0].username == "mybot123bot456bot789" + + def test_complex_regex_patterns_safe(self, create_comment_event): + """Test that complex patterns are handled safely.""" + event = create_comment_event( + "@test @test-bot @test-bot-123 @testbotbotbot @verylongusername123456789" + ) + + # Various potentially problematic patterns + patterns = [ + re.compile(r".*bot.*"), # Wildcards + re.compile(r"test.*"), # Leading wildcard + re.compile(r".*"), # Match all + re.compile(r"(test|bot)+"), # Alternation with quantifier + re.compile(r"[a-z]+[0-9]+"), # Character classes with quantifiers + ] + + for pattern in patterns: + start_time = time.time() + extract_mentions_from_event(event, pattern) + execution_time = time.time() - start_time + + # All patterns should execute quickly + assert execution_time < 0.1 + + def test_github_username_constraints(self, create_comment_event): + """Test that only valid GitHub usernames are extracted.""" + event = create_comment_event( + "@validuser @Valid-User-123 @-invalid @invalid- @in--valid " + "@toolongusernamethatexceedsthirtyninecharacters @123startswithnumber" + ) + + mentions = extract_mentions_from_event(event, re.compile(r".*")) + + # Check what usernames were actually extracted + extracted_usernames = [m.username for m in mentions] + + # The regex extracts: + # - validuser (valid) + # - Valid-User-123 (valid) + # - invalid (from @invalid-, hyphen at end not included) + # - in (from @in--valid, stops at double hyphen) + # - toolongusernamethatexceedsthirtyninecha (truncated to 39 chars) + # - 123startswithnumber (valid - GitHub allows starting with numbers) + assert len(mentions) == 6 + assert "validuser" in extracted_usernames + assert "Valid-User-123" in extracted_usernames + # These are extracted but not ideal - the regex follows GitHub's rules + assert "invalid" in extracted_usernames # From @invalid- + assert "in" in extracted_usernames # From @in--valid + assert ( + "toolongusernamethatexceedsthirtyninecha" in extracted_usernames + ) # Truncated + assert "123startswithnumber" in extracted_usernames # Valid GitHub username + + def test_performance_with_many_mentions(self, create_comment_event): + """Test performance with many mentions in a single comment.""" + # Create a comment with 100 mentions + usernames = [f"@user{i}" for i in range(100)] + comment_body = " ".join(usernames) + " Please review all" + event = create_comment_event(comment_body) + + pattern = re.compile(r"user\d+") + + start_time = time.time() + mentions = extract_mentions_from_event(event, pattern) + execution_time = time.time() - start_time + + # Should handle many mentions efficiently + assert execution_time < 0.5 + assert len(mentions) == 100 + + # Verify all mentions are correctly parsed + for i, mention in enumerate(mentions): + assert mention.username == f"user{i}" diff --git a/tests/test_routing.py b/tests/test_routing.py index c2a7208..e344bd4 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -239,8 +239,8 @@ def test_multiple_decorators_on_same_function( @test_router.mention(pattern="?") def help_handler(event, *args, **kwargs): mention = kwargs.get("context") - if mention and mention.triggered_by: - text = mention.triggered_by.text.strip() + if mention and mention.mention: + text = mention.mention.text.strip() if text in call_counts: call_counts[text] += 1 @@ -534,7 +534,7 @@ def deploy_handler(event, *args, **kwargs): mention = captured_kwargs["context"] assert mention.comment.body == "@bot deploy" - assert mention.triggered_by.text == "deploy" + assert mention.mention.text == "deploy" assert mention.scope.name == "PR" @@ -577,16 +577,16 @@ def test_handler(event, *args, **kwargs): assert comment.url == "https://github.com/test/repo/issues/1#issuecomment-123" assert len(comment.mentions) == 1 - triggered = captured_mention.triggered_by + triggered = captured_mention.mention assert triggered.username == "bot" assert triggered.text == "test" assert triggered.position == 0 - assert triggered.line_number == 1 + assert triggered.line_info.lineno == 1 assert captured_mention.scope.name == "ISSUE" - def test_multiple_mentions_triggered_by(self, test_router, get_mock_github_api): + def test_multiple_mentions_mention(self, test_router, get_mock_github_api): handler_called = False captured_mention = None @@ -618,8 +618,8 @@ def deploy_handler(event, *args, **kwargs): assert handler_called assert captured_mention is not None assert len(captured_mention.comment.mentions) == 2 - assert captured_mention.triggered_by.text == "deploy production" - assert captured_mention.triggered_by.line_number == 2 + assert captured_mention.mention.text == "deploy production" + assert captured_mention.mention.line_info.lineno == 2 first_mention = captured_mention.comment.mentions[0] second_mention = captured_mention.comment.mentions[1] @@ -657,8 +657,8 @@ def general_handler(event, *args, **kwargs): test_router.dispatch(event, mock_gh) assert handler_called - assert captured_mention.triggered_by.text == "can you help me?" - assert captured_mention.triggered_by.username == "bot" + assert captured_mention.mention.text == "can you help me?" + assert captured_mention.mention.username == "bot" @pytest.mark.asyncio async def test_async_mention_context_structure( @@ -694,7 +694,7 @@ async def async_handler(event, *args, **kwargs): assert handler_called assert captured_mention.comment.body == "@bot async-test now" - assert captured_mention.triggered_by.text == "async-test now" + assert captured_mention.mention.text == "async-test now" class TestFlexibleMentionTriggers: @@ -725,8 +725,8 @@ def deploy_handler(event, *args, **kwargs): test_router.dispatch(event, mock_gh) assert handler_called - assert captured_mention.triggered_by.match is not None - assert captured_mention.triggered_by.match.group(0) == "deploy" + assert captured_mention.mention.match is not None + assert captured_mention.mention.match.group(0) == "deploy" # Should not match - pattern in middle handler_called = False @@ -759,8 +759,8 @@ def deploy_env_handler(event, *args, **kwargs): test_router.dispatch(event, mock_gh) assert handler_called - assert captured_mention.triggered_by.match is not None - assert captured_mention.triggered_by.match.group("env") == "staging" + assert captured_mention.mention.match is not None + assert captured_mention.mention.match.group("env") == "staging" def test_username_parameter_exact(self, test_router, get_mock_github_api): handler_called = False @@ -826,7 +826,7 @@ def test_username_all_mentions(self, test_router, get_mock_github_api): @test_router.mention(username=re.compile(r".*")) def all_mentions_handler(event, *args, **kwargs): mention = kwargs.get("context") - mentions_seen.append(mention.triggered_by.username) + mentions_seen.append(mention.mention.username) event = sansio.Event( { @@ -919,7 +919,7 @@ def test_multiple_decorators_different_patterns( @test_router.mention(pattern=re.compile(r"release")) def deploy_handler(event, *args, **kwargs): mention = kwargs.get("context") - patterns_matched.append(mention.triggered_by.text.split()[0]) + patterns_matched.append(mention.mention.text.split()[0]) event = sansio.Event( { @@ -942,7 +942,7 @@ def test_question_pattern(self, test_router, get_mock_github_api): @test_router.mention(pattern=re.compile(r".*\?$")) def question_handler(event, *args, **kwargs): mention = kwargs.get("context") - questions_received.append(mention.triggered_by.text) + questions_received.append(mention.mention.text) event = sansio.Event( { From 92341fcff2e6b6b2af29944dae82aaa9c571e258 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 19 Jun 2025 16:51:52 +0000 Subject: [PATCH 20/28] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_mentions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_mentions.py b/tests/test_mentions.py index b44c400..aa0770d 100644 --- a/tests/test_mentions.py +++ b/tests/test_mentions.py @@ -9,8 +9,8 @@ from django_github_app.mentions import Comment from django_github_app.mentions import MentionScope -from django_github_app.mentions import get_match from django_github_app.mentions import extract_mentions_from_event +from django_github_app.mentions import get_match @pytest.fixture From cd6dd96d52b276ac8dc04e0853e10c322f450b15 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 19 Jun 2025 12:02:06 -0500 Subject: [PATCH 21/28] Use app settings SLUG as default mention pattern --- src/django_github_app/mentions.py | 6 ++++-- tests/test_mentions.py | 8 +++++++- tests/test_routing.py | 6 ++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/django_github_app/mentions.py b/src/django_github_app/mentions.py index 80e10f8..f4516b6 100644 --- a/src/django_github_app/mentions.py +++ b/src/django_github_app/mentions.py @@ -10,6 +10,8 @@ from django.utils import timezone from gidgethub import sansio +from .conf import app_settings + class EventAction(NamedTuple): event: str @@ -166,9 +168,9 @@ def extract_mentions_from_event( if not comment: return [] - # If no pattern specified, use bot username (TODO: get from settings) + # If no pattern specified, use bot username from settings if username_pattern is None: - username_pattern = "bot" # Placeholder + username_pattern = app_settings.SLUG mentions: list[ParsedMention] = [] potential_mentions = extract_all_mentions(comment) diff --git a/tests/test_mentions.py b/tests/test_mentions.py index b44c400..4108ff9 100644 --- a/tests/test_mentions.py +++ b/tests/test_mentions.py @@ -9,8 +9,14 @@ from django_github_app.mentions import Comment from django_github_app.mentions import MentionScope -from django_github_app.mentions import get_match from django_github_app.mentions import extract_mentions_from_event +from django_github_app.mentions import get_match + + +@pytest.fixture(autouse=True) +def setup_test_app_name(override_app_settings): + with override_app_settings(NAME="bot"): + yield @pytest.fixture diff --git a/tests/test_routing.py b/tests/test_routing.py index e344bd4..e990b58 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -14,6 +14,12 @@ from django_github_app.views import BaseWebhookView +@pytest.fixture(autouse=True) +def setup_test_app_name(override_app_settings): + with override_app_settings(NAME="bot"): + yield + + @pytest.fixture(autouse=True) def test_router(): import django_github_app.views From b299a1c545bc6b14b5e02f7c64679680ebc1cda1 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 19 Jun 2025 18:08:24 -0500 Subject: [PATCH 22/28] Replace manual event creation with consolidated create_event fixture --- src/django_github_app/mentions.py | 11 +- tests/test_mentions.py | 155 ++++---- tests/test_routing.py | 583 ++++++++++++++---------------- 3 files changed, 369 insertions(+), 380 deletions(-) diff --git a/src/django_github_app/mentions.py b/src/django_github_app/mentions.py index f4516b6..a194d7f 100644 --- a/src/django_github_app/mentions.py +++ b/src/django_github_app/mentions.py @@ -160,15 +160,12 @@ class ParsedMention: def extract_mentions_from_event( event: sansio.Event, username_pattern: str | re.Pattern[str] | None = None ) -> list[ParsedMention]: - comment_data = event.data.get("comment", {}) - if comment_data is None: - comment_data = {} - comment = comment_data.get("body", "") + comment = event.data.get("comment", {}).get("body", "") if not comment: return [] - # If no pattern specified, use bot username from settings + # If no pattern specified, use github app name from settings if username_pattern is None: username_pattern = app_settings.SLUG @@ -297,10 +294,8 @@ def from_event( ) -def matches_pattern(text: str, pattern: str | re.Pattern[str] | None) -> bool: +def matches_pattern(text: str, pattern: str | re.Pattern[str]) -> bool: match pattern: - case None: - return True case re.Pattern(): return pattern.fullmatch(text) is not None case str(): diff --git a/tests/test_mentions.py b/tests/test_mentions.py index 4108ff9..e3e0877 100644 --- a/tests/test_mentions.py +++ b/tests/test_mentions.py @@ -4,6 +4,7 @@ import time import pytest +from django.test import override_settings from django.utils import timezone from gidgethub import sansio @@ -19,19 +20,9 @@ def setup_test_app_name(override_app_settings): yield -@pytest.fixture -def create_comment_event(): - def _create(body: str) -> sansio.Event: - return sansio.Event( - {"comment": {"body": body}}, event="issue_comment", delivery_id="test" - ) - - return _create - - class TestParseMentions: - def test_simple_mention_with_command(self, create_comment_event): - event = create_comment_event("@mybot help") + def test_simple_mention_with_command(self, create_event): + event = create_event("issue_comment", comment="@mybot help") mentions = extract_mentions_from_event(event, "mybot") assert len(mentions) == 1 @@ -40,46 +31,50 @@ def test_simple_mention_with_command(self, create_comment_event): assert mentions[0].position == 0 assert mentions[0].line_info.lineno == 1 - def test_mention_without_command(self, create_comment_event): - event = create_comment_event("@mybot") + def test_mention_without_command(self, create_event): + event = create_event("issue_comment", comment="@mybot") mentions = extract_mentions_from_event(event, "mybot") assert len(mentions) == 1 assert mentions[0].username == "mybot" assert mentions[0].text == "" - def test_case_insensitive_matching(self, create_comment_event): - event = create_comment_event("@MyBot help") + def test_case_insensitive_matching(self, create_event): + event = create_event("issue_comment", comment="@MyBot help") mentions = extract_mentions_from_event(event, "mybot") assert len(mentions) == 1 assert mentions[0].username == "MyBot" # Username is preserved as found assert mentions[0].text == "help" - def test_command_case_normalization(self, create_comment_event): - event = create_comment_event("@mybot HELP") + def test_command_case_normalization(self, create_event): + event = create_event("issue_comment", comment="@mybot HELP") mentions = extract_mentions_from_event(event, "mybot") assert len(mentions) == 1 # Command case is preserved in text, normalization happens elsewhere assert mentions[0].text == "HELP" - def test_multiple_mentions(self, create_comment_event): - event = create_comment_event("@mybot help and then @mybot deploy") + def test_multiple_mentions(self, create_event): + event = create_event( + "issue_comment", comment="@mybot help and then @mybot deploy" + ) mentions = extract_mentions_from_event(event, "mybot") assert len(mentions) == 2 assert mentions[0].text == "help and then" assert mentions[1].text == "deploy" - def test_ignore_other_mentions(self, create_comment_event): - event = create_comment_event("@otheruser help @mybot deploy @someone else") + def test_ignore_other_mentions(self, create_event): + event = create_event( + "issue_comment", comment="@otheruser help @mybot deploy @someone else" + ) mentions = extract_mentions_from_event(event, "mybot") assert len(mentions) == 1 assert mentions[0].text == "deploy" - def test_mention_in_code_block(self, create_comment_event): + def test_mention_in_code_block(self, create_event): text = """ Here's some text ``` @@ -87,104 +82,104 @@ def test_mention_in_code_block(self, create_comment_event): ``` @mybot deploy """ - event = create_comment_event(text) + event = create_event("issue_comment", comment=text) mentions = extract_mentions_from_event(event, "mybot") assert len(mentions) == 1 assert mentions[0].text == "deploy" - def test_mention_in_inline_code(self, create_comment_event): - event = create_comment_event( - "Use `@mybot help` for help, or just @mybot deploy" + def test_mention_in_inline_code(self, create_event): + event = create_event( + "issue_comment", comment="Use `@mybot help` for help, or just @mybot deploy" ) mentions = extract_mentions_from_event(event, "mybot") assert len(mentions) == 1 assert mentions[0].text == "deploy" - def test_mention_in_quote(self, create_comment_event): + def test_mention_in_quote(self, create_event): text = """ > @mybot help @mybot deploy """ - event = create_comment_event(text) + event = create_event("issue_comment", comment=text) mentions = extract_mentions_from_event(event, "mybot") assert len(mentions) == 1 assert mentions[0].text == "deploy" - def test_empty_text(self, create_comment_event): - event = create_comment_event("") + def test_empty_text(self, create_event): + event = create_event("issue_comment", comment="") mentions = extract_mentions_from_event(event, "mybot") assert mentions == [] - def test_none_text(self, create_comment_event): + def test_none_text(self, create_event): # Create an event with no comment body - event = sansio.Event({}, event="issue_comment", delivery_id="test") + event = create_event("issue_comment") mentions = extract_mentions_from_event(event, "mybot") assert mentions == [] - def test_mention_at_start_of_line(self, create_comment_event): - event = create_comment_event("@mybot help") + def test_mention_at_start_of_line(self, create_event): + event = create_event("issue_comment", comment="@mybot help") mentions = extract_mentions_from_event(event, "mybot") assert len(mentions) == 1 assert mentions[0].text == "help" - def test_mention_in_middle_of_text(self, create_comment_event): - event = create_comment_event("Hey @mybot help me") + def test_mention_in_middle_of_text(self, create_event): + event = create_event("issue_comment", comment="Hey @mybot help me") mentions = extract_mentions_from_event(event, "mybot") assert len(mentions) == 1 assert mentions[0].text == "help me" - def test_mention_with_punctuation_after(self, create_comment_event): - event = create_comment_event("@mybot help!") + def test_mention_with_punctuation_after(self, create_event): + event = create_event("issue_comment", comment="@mybot help!") mentions = extract_mentions_from_event(event, "mybot") assert len(mentions) == 1 assert mentions[0].text == "help!" - def test_hyphenated_username(self, create_comment_event): - event = create_comment_event("@my-bot help") + def test_hyphenated_username(self, create_event): + event = create_event("issue_comment", comment="@my-bot help") mentions = extract_mentions_from_event(event, "my-bot") assert len(mentions) == 1 assert mentions[0].username == "my-bot" assert mentions[0].text == "help" - def test_underscore_username(self, create_comment_event): + def test_underscore_username(self, create_event): # GitHub usernames don't support underscores - event = create_comment_event("@my_bot help") + event = create_event("issue_comment", comment="@my_bot help") mentions = extract_mentions_from_event(event, "my_bot") assert len(mentions) == 0 # Should not match invalid username - def test_no_space_after_mention(self, create_comment_event): - event = create_comment_event("@mybot, please help") + def test_no_space_after_mention(self, create_event): + event = create_event("issue_comment", comment="@mybot, please help") mentions = extract_mentions_from_event(event, "mybot") assert len(mentions) == 1 assert mentions[0].text == ", please help" - def test_multiple_spaces_before_command(self, create_comment_event): - event = create_comment_event("@mybot help") + def test_multiple_spaces_before_command(self, create_event): + event = create_event("issue_comment", comment="@mybot help") mentions = extract_mentions_from_event(event, "mybot") assert len(mentions) == 1 assert mentions[0].text == "help" # Whitespace is stripped - def test_hyphenated_command(self, create_comment_event): - event = create_comment_event("@mybot async-test") + def test_hyphenated_command(self, create_event): + event = create_event("issue_comment", comment="@mybot async-test") mentions = extract_mentions_from_event(event, "mybot") assert len(mentions) == 1 assert mentions[0].text == "async-test" - def test_special_character_command(self, create_comment_event): - event = create_comment_event("@mybot ?") + def test_special_character_command(self, create_event): + event = create_event("issue_comment", comment="@mybot ?") mentions = extract_mentions_from_event(event, "mybot") assert len(mentions) == 1 @@ -434,6 +429,28 @@ def test_from_event_timezone_handling(self): assert comment.created_at.tzinfo is not None assert comment.created_at.isoformat() == "2024-01-01T12:00:00+00:00" + def test_from_event_timezone_handling_use_tz_false(self): + event = sansio.Event( + { + "comment": { + "body": "Test", + "user": {"login": "user"}, + "created_at": "2024-01-01T12:00:00Z", + "html_url": "", + } + }, + event="issue_comment", + delivery_id="test-7", + ) + + with override_settings(USE_TZ=False, TIME_ZONE="UTC"): + comment = Comment.from_event(event) + + # Check that the datetime is naive (no timezone info) + assert comment.created_at.tzinfo is None + # When USE_TZ=False with TIME_ZONE="UTC", the naive datetime should match the original UTC time + assert comment.created_at.isoformat() == "2024-01-01T12:00:00" + class TestPatternMatching: def test_get_match_none(self): @@ -577,12 +594,12 @@ def test_extract_mentions_from_event_all(self): class TestReDoSProtection: """Test that the ReDoS vulnerability has been fixed.""" - def test_redos_vulnerability_fixed(self, create_comment_event): + def test_redos_vulnerability_fixed(self, create_event): """Test that malicious input doesn't cause catastrophic backtracking.""" # Create a malicious comment that would cause ReDoS with the old implementation # Pattern: (bot|ai|assistant)+ matching "botbotbot...x" malicious_username = "bot" * 20 + "x" - event = create_comment_event(f"@{malicious_username} hello") + event = create_event("issue_comment", comment=f"@{malicious_username} hello") # This pattern would cause catastrophic backtracking in the old implementation pattern = re.compile(r"(bot|ai|assistant)+") @@ -601,9 +618,11 @@ def test_redos_vulnerability_fixed(self, create_comment_event): mentions[0].username == "botbotbotbotbotbotbotbotbotbotbotbotbot" ) # 39 chars - def test_nested_quantifier_pattern(self, create_comment_event): + def test_nested_quantifier_pattern(self, create_event): """Test patterns with nested quantifiers don't cause issues.""" - event = create_comment_event("@deploy-bot-bot-bot test command") + event = create_event( + "issue_comment", comment="@deploy-bot-bot-bot test command" + ) # This type of pattern could cause issues: (word)+ pattern = re.compile(r"(deploy|bot)+") @@ -616,9 +635,9 @@ def test_nested_quantifier_pattern(self, create_comment_event): # Username contains hyphens, so it won't match this pattern assert len(mentions) == 0 - def test_alternation_with_quantifier(self, create_comment_event): + def test_alternation_with_quantifier(self, create_event): """Test alternation patterns with quantifiers.""" - event = create_comment_event("@mybot123bot456bot789 deploy") + event = create_event("issue_comment", comment="@mybot123bot456bot789 deploy") # Pattern like (a|b)* that could be dangerous pattern = re.compile(r"(my|bot|[0-9])+") @@ -632,10 +651,11 @@ def test_alternation_with_quantifier(self, create_comment_event): assert len(mentions) == 1 assert mentions[0].username == "mybot123bot456bot789" - def test_complex_regex_patterns_safe(self, create_comment_event): + def test_complex_regex_patterns_safe(self, create_event): """Test that complex patterns are handled safely.""" - event = create_comment_event( - "@test @test-bot @test-bot-123 @testbotbotbot @verylongusername123456789" + event = create_event( + "issue_comment", + comment="@test @test-bot @test-bot-123 @testbotbotbot @verylongusername123456789", ) # Various potentially problematic patterns @@ -655,11 +675,14 @@ def test_complex_regex_patterns_safe(self, create_comment_event): # All patterns should execute quickly assert execution_time < 0.1 - def test_github_username_constraints(self, create_comment_event): + def test_github_username_constraints(self, create_event): """Test that only valid GitHub usernames are extracted.""" - event = create_comment_event( - "@validuser @Valid-User-123 @-invalid @invalid- @in--valid " - "@toolongusernamethatexceedsthirtyninecharacters @123startswithnumber" + event = create_event( + "issue_comment", + comment=( + "@validuser @Valid-User-123 @-invalid @invalid- @in--valid " + "@toolongusernamethatexceedsthirtyninecharacters @123startswithnumber" + ), ) mentions = extract_mentions_from_event(event, re.compile(r".*")) @@ -685,12 +708,12 @@ def test_github_username_constraints(self, create_comment_event): ) # Truncated assert "123startswithnumber" in extracted_usernames # Valid GitHub username - def test_performance_with_many_mentions(self, create_comment_event): + def test_performance_with_many_mentions(self, create_event): """Test performance with many mentions in a single comment.""" # Create a comment with 100 mentions usernames = [f"@user{i}" for i in range(100)] comment_body = " ".join(usernames) + " Please review all" - event = create_comment_event(comment_body) + event = create_event("issue_comment", comment=comment_body) pattern = re.compile(r"user\d+") diff --git a/tests/test_routing.py b/tests/test_routing.py index e990b58..18ed42d 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -6,7 +6,6 @@ import pytest from django.http import HttpRequest from django.http import JsonResponse -from gidgethub import sansio from django_github_app.github import SyncGitHubAPI from django_github_app.mentions import MentionScope @@ -123,7 +122,9 @@ def test_router_memory_stress_test_legacy(self): class TestMentionDecorator: - def test_basic_mention_no_pattern(self, test_router, get_mock_github_api): + def test_basic_mention_no_pattern( + self, test_router, get_mock_github_api, create_event + ): handler_called = False handler_args = None @@ -133,14 +134,12 @@ def handle_mention(event, *args, **kwargs): handler_called = True handler_args = (event, args, kwargs) - event = sansio.Event( - { - "action": "created", - "comment": {"body": "@bot hello", "user": {"login": "testuser"}}, - "issue": {"number": 1}, - "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, - }, - event="issue_comment", + event = create_event( + "issue_comment", + action="created", + comment={"body": "@bot hello", "user": {"login": "testuser"}}, + issue={"number": 1}, + repository={"owner": {"login": "testowner"}, "name": "testrepo"}, delivery_id="123", ) mock_gh = get_mock_github_api({}) @@ -149,7 +148,7 @@ def handle_mention(event, *args, **kwargs): assert handler_called assert handler_args[0] == event - def test_mention_with_pattern(self, test_router, get_mock_github_api): + def test_mention_with_pattern(self, test_router, get_mock_github_api, create_event): handler_called = False @test_router.mention(pattern="help") @@ -158,14 +157,12 @@ def help_handler(event, *args, **kwargs): handler_called = True return "help response" - event = sansio.Event( - { - "action": "created", - "comment": {"body": "@bot help", "user": {"login": "testuser"}}, - "issue": {"number": 2}, - "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, - }, - event="issue_comment", + event = create_event( + "issue_comment", + action="created", + comment={"body": "@bot help", "user": {"login": "testuser"}}, + issue={"number": 2}, + repository={"owner": {"login": "testowner"}, "name": "testrepo"}, delivery_id="123", ) mock_gh = get_mock_github_api({}) @@ -173,7 +170,7 @@ def help_handler(event, *args, **kwargs): assert handler_called - def test_mention_with_scope(self, test_router, get_mock_github_api): + def test_mention_with_scope(self, test_router, get_mock_github_api, create_event): pr_handler_called = False @test_router.mention(pattern="deploy", scope=MentionScope.PR) @@ -183,27 +180,23 @@ def deploy_handler(event, *args, **kwargs): mock_gh = get_mock_github_api({}) - pr_event = sansio.Event( - { - "action": "created", - "comment": {"body": "@bot deploy", "user": {"login": "testuser"}}, - "pull_request": {"number": 3}, - "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, - }, - event="pull_request_review_comment", + pr_event = create_event( + "pull_request_review_comment", + action="created", + comment={"body": "@bot deploy", "user": {"login": "testuser"}}, + pull_request={"number": 3}, + repository={"owner": {"login": "testowner"}, "name": "testrepo"}, delivery_id="123", ) test_router.dispatch(pr_event, mock_gh) assert pr_handler_called - issue_event = sansio.Event( - { - "action": "created", - "comment": {"body": "@bot deploy", "user": {"login": "testuser"}}, - "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, - }, - event="commit_comment", # This is NOT a PR event + issue_event = create_event( + "commit_comment", # This is NOT a PR event + action="created", + comment={"body": "@bot deploy", "user": {"login": "testuser"}}, + repository={"owner": {"login": "testowner"}, "name": "testrepo"}, delivery_id="124", ) pr_handler_called = False # Reset @@ -212,7 +205,9 @@ def deploy_handler(event, *args, **kwargs): assert not pr_handler_called - def test_case_insensitive_pattern(self, test_router, get_mock_github_api): + def test_case_insensitive_pattern( + self, test_router, get_mock_github_api, create_event + ): handler_called = False @test_router.mention(pattern="HELP") @@ -220,14 +215,12 @@ def help_handler(event, *args, **kwargs): nonlocal handler_called handler_called = True - event = sansio.Event( - { - "action": "created", - "comment": {"body": "@bot help", "user": {"login": "testuser"}}, - "issue": {"number": 4}, - "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, - }, - event="issue_comment", + event = create_event( + "issue_comment", + action="created", + comment={"body": "@bot help", "user": {"login": "testuser"}}, + issue={"number": 4}, + repository={"owner": {"login": "testowner"}, "name": "testrepo"}, delivery_id="123", ) mock_gh = get_mock_github_api({}) @@ -236,7 +229,7 @@ def help_handler(event, *args, **kwargs): assert handler_called def test_multiple_decorators_on_same_function( - self, test_router, get_mock_github_api + self, test_router, get_mock_github_api, create_event ): call_counts = {"help": 0, "h": 0, "?": 0} @@ -251,17 +244,15 @@ def help_handler(event, *args, **kwargs): call_counts[text] += 1 for pattern in ["help", "h", "?"]: - event = sansio.Event( - { - "action": "created", - "comment": { - "body": f"@bot {pattern}", - "user": {"login": "testuser"}, - }, - "issue": {"number": 5}, - "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, + event = create_event( + "issue_comment", + action="created", + comment={ + "body": f"@bot {pattern}", + "user": {"login": "testuser"}, }, - event="issue_comment", + issue={"number": 5}, + repository={"owner": {"login": "testowner"}, "name": "testrepo"}, delivery_id=f"123-{pattern}", ) mock_gh = get_mock_github_api({}) @@ -275,7 +266,9 @@ def help_handler(event, *args, **kwargs): assert call_counts["h"] == 1 # Matched only by "h" pattern assert call_counts["?"] == 1 # Matched only by "?" pattern - def test_async_mention_handler(self, test_router, aget_mock_github_api): + def test_async_mention_handler( + self, test_router, aget_mock_github_api, create_event + ): handler_called = False @test_router.mention(pattern="async-test") @@ -284,14 +277,12 @@ async def async_handler(event, *args, **kwargs): handler_called = True return "async response" - event = sansio.Event( - { - "action": "created", - "comment": {"body": "@bot async-test", "user": {"login": "testuser"}}, - "issue": {"number": 1}, - "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, - }, - event="issue_comment", + event = create_event( + "issue_comment", + action="created", + comment={"body": "@bot async-test", "user": {"login": "testuser"}}, + issue={"number": 1}, + repository={"owner": {"login": "testowner"}, "name": "testrepo"}, delivery_id="123", ) @@ -300,7 +291,7 @@ async def async_handler(event, *args, **kwargs): assert handler_called - def test_sync_mention_handler(self, test_router, get_mock_github_api): + def test_sync_mention_handler(self, test_router, get_mock_github_api, create_event): handler_called = False @test_router.mention(pattern="sync-test") @@ -309,14 +300,12 @@ def sync_handler(event, *args, **kwargs): handler_called = True return "sync response" - event = sansio.Event( - { - "action": "created", - "comment": {"body": "@bot sync-test", "user": {"login": "testuser"}}, - "issue": {"number": 6}, - "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, - }, - event="issue_comment", + event = create_event( + "issue_comment", + action="created", + comment={"body": "@bot sync-test", "user": {"login": "testuser"}}, + issue={"number": 6}, + repository={"owner": {"login": "testowner"}, "name": "testrepo"}, delivery_id="123", ) mock_gh = get_mock_github_api({}) @@ -325,7 +314,7 @@ def sync_handler(event, *args, **kwargs): assert handler_called def test_scope_validation_issue_comment_on_issue( - self, test_router, get_mock_github_api + self, test_router, get_mock_github_api, create_event ): handler_called = False @@ -334,14 +323,12 @@ def issue_handler(event, *args, **kwargs): nonlocal handler_called handler_called = True - event = sansio.Event( - { - "action": "created", - "issue": {"title": "Bug report", "number": 123}, - "comment": {"body": "@bot issue-only", "user": {"login": "testuser"}}, - "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, - }, - event="issue_comment", + event = create_event( + "issue_comment", + action="created", + issue={"title": "Bug report", "number": 123}, + comment={"body": "@bot issue-only", "user": {"login": "testuser"}}, + repository={"owner": {"login": "testowner"}, "name": "testrepo"}, delivery_id="123", ) mock_gh = get_mock_github_api({}) @@ -350,7 +337,7 @@ def issue_handler(event, *args, **kwargs): assert handler_called def test_scope_validation_issue_comment_on_pr( - self, test_router, get_mock_github_api + self, test_router, get_mock_github_api, create_event ): handler_called = False @@ -360,18 +347,16 @@ def issue_handler(event, *args, **kwargs): handler_called = True # Issue comment on a pull request (has pull_request field) - event = sansio.Event( - { - "action": "created", - "issue": { - "title": "PR title", - "number": 456, - "pull_request": {"url": "https://api.github.com/..."}, - }, - "comment": {"body": "@bot issue-only", "user": {"login": "testuser"}}, - "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, + event = create_event( + "issue_comment", + action="created", + issue={ + "title": "PR title", + "number": 456, + "pull_request": {"url": "https://api.github.com/..."}, }, - event="issue_comment", + comment={"body": "@bot issue-only", "user": {"login": "testuser"}}, + repository={"owner": {"login": "testowner"}, "name": "testrepo"}, delivery_id="123", ) mock_gh = get_mock_github_api({}) @@ -379,7 +364,9 @@ def issue_handler(event, *args, **kwargs): assert not handler_called - def test_scope_validation_pr_scope_on_pr(self, test_router, get_mock_github_api): + def test_scope_validation_pr_scope_on_pr( + self, test_router, get_mock_github_api, create_event + ): handler_called = False @test_router.mention(pattern="pr-only", scope=MentionScope.PR) @@ -387,18 +374,16 @@ def pr_handler(event, *args, **kwargs): nonlocal handler_called handler_called = True - event = sansio.Event( - { - "action": "created", - "issue": { - "title": "PR title", - "number": 456, - "pull_request": {"url": "https://api.github.com/..."}, - }, - "comment": {"body": "@bot pr-only", "user": {"login": "testuser"}}, - "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, + event = create_event( + "issue_comment", + action="created", + issue={ + "title": "PR title", + "number": 456, + "pull_request": {"url": "https://api.github.com/..."}, }, - event="issue_comment", + comment={"body": "@bot pr-only", "user": {"login": "testuser"}}, + repository={"owner": {"login": "testowner"}, "name": "testrepo"}, delivery_id="123", ) mock_gh = get_mock_github_api({}) @@ -406,7 +391,9 @@ def pr_handler(event, *args, **kwargs): assert handler_called - def test_scope_validation_pr_scope_on_issue(self, test_router, get_mock_github_api): + def test_scope_validation_pr_scope_on_issue( + self, test_router, get_mock_github_api, create_event + ): handler_called = False @test_router.mention(pattern="pr-only", scope=MentionScope.PR) @@ -414,14 +401,12 @@ def pr_handler(event, *args, **kwargs): nonlocal handler_called handler_called = True - event = sansio.Event( - { - "action": "created", - "issue": {"title": "Bug report", "number": 123}, - "comment": {"body": "@bot pr-only", "user": {"login": "testuser"}}, - "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, - }, - event="issue_comment", + event = create_event( + "issue_comment", + action="created", + issue={"title": "Bug report", "number": 123}, + comment={"body": "@bot pr-only", "user": {"login": "testuser"}}, + repository={"owner": {"login": "testowner"}, "name": "testrepo"}, delivery_id="123", ) mock_gh = get_mock_github_api({}) @@ -429,7 +414,9 @@ def pr_handler(event, *args, **kwargs): assert not handler_called - def test_scope_validation_commit_scope(self, test_router, get_mock_github_api): + def test_scope_validation_commit_scope( + self, test_router, get_mock_github_api, create_event + ): """Test that COMMIT scope works for commit comments.""" handler_called = False @@ -438,14 +425,12 @@ def commit_handler(event, *args, **kwargs): nonlocal handler_called handler_called = True - event = sansio.Event( - { - "action": "created", - "comment": {"body": "@bot commit-only", "user": {"login": "testuser"}}, - "commit": {"sha": "abc123"}, - "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, - }, - event="commit_comment", + event = create_event( + "commit_comment", + action="created", + comment={"body": "@bot commit-only", "user": {"login": "testuser"}}, + commit={"sha": "abc123"}, + repository={"owner": {"login": "testowner"}, "name": "testrepo"}, delivery_id="123", ) mock_gh = get_mock_github_api({}) @@ -453,7 +438,9 @@ def commit_handler(event, *args, **kwargs): assert handler_called - def test_scope_validation_no_scope(self, test_router, get_mock_github_api): + def test_scope_validation_no_scope( + self, test_router, get_mock_github_api, create_event + ): call_count = 0 @test_router.mention(pattern="all-contexts") @@ -463,49 +450,45 @@ def all_handler(event, *args, **kwargs): mock_gh = get_mock_github_api({}) - event = sansio.Event( - { - "action": "created", - "issue": {"title": "Issue", "number": 1}, - "comment": {"body": "@bot all-contexts", "user": {"login": "testuser"}}, - "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, - }, - event="issue_comment", + event = create_event( + "issue_comment", + action="created", + issue={"title": "Issue", "number": 1}, + comment={"body": "@bot all-contexts", "user": {"login": "testuser"}}, + repository={"owner": {"login": "testowner"}, "name": "testrepo"}, delivery_id="123", ) test_router.dispatch(event, mock_gh) - event = sansio.Event( - { - "action": "created", - "issue": { - "title": "PR", - "number": 2, - "pull_request": {"url": "..."}, - }, - "comment": {"body": "@bot all-contexts", "user": {"login": "testuser"}}, - "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, + event = create_event( + "issue_comment", + action="created", + issue={ + "title": "PR", + "number": 2, + "pull_request": {"url": "..."}, }, - event="issue_comment", + comment={"body": "@bot all-contexts", "user": {"login": "testuser"}}, + repository={"owner": {"login": "testowner"}, "name": "testrepo"}, delivery_id="124", ) test_router.dispatch(event, mock_gh) - event = sansio.Event( - { - "action": "created", - "comment": {"body": "@bot all-contexts", "user": {"login": "testuser"}}, - "commit": {"sha": "abc123"}, - "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, - }, - event="commit_comment", + event = create_event( + "commit_comment", + action="created", + comment={"body": "@bot all-contexts", "user": {"login": "testuser"}}, + commit={"sha": "abc123"}, + repository={"owner": {"login": "testowner"}, "name": "testrepo"}, delivery_id="125", ) test_router.dispatch(event, mock_gh) assert call_count == 3 - def test_mention_enrichment_pr_scope(self, test_router, get_mock_github_api): + def test_mention_enrichment_pr_scope( + self, test_router, get_mock_github_api, create_event + ): handler_called = False captured_kwargs = {} @@ -515,19 +498,17 @@ def deploy_handler(event, *args, **kwargs): handler_called = True captured_kwargs = kwargs.copy() - event = sansio.Event( - { - "action": "created", - "comment": {"body": "@bot deploy", "user": {"login": "dev"}}, - "issue": { - "number": 42, - "pull_request": { - "url": "https://api.github.com/repos/test/repo/pulls/42" - }, + event = create_event( + "issue_comment", + action="created", + comment={"body": "@bot deploy", "user": {"login": "dev"}}, + issue={ + "number": 42, + "pull_request": { + "url": "https://api.github.com/repos/test/repo/pulls/42" }, - "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, }, - event="issue_comment", + repository={"owner": {"login": "testowner"}, "name": "testrepo"}, delivery_id="999", ) @@ -545,7 +526,9 @@ def deploy_handler(event, *args, **kwargs): class TestUpdatedMentionContext: - def test_mention_context_structure(self, test_router, get_mock_github_api): + def test_mention_context_structure( + self, test_router, get_mock_github_api, create_event + ): handler_called = False captured_mention = None @@ -555,19 +538,17 @@ def test_handler(event, *args, **kwargs): handler_called = True captured_mention = kwargs.get("context") - event = sansio.Event( - { - "action": "created", - "comment": { - "body": "@bot test", - "user": {"login": "testuser"}, - "created_at": "2024-01-01T12:00:00Z", - "html_url": "https://github.com/test/repo/issues/1#issuecomment-123", - }, - "issue": {"number": 1}, - "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, + event = create_event( + "issue_comment", + action="created", + comment={ + "body": "@bot test", + "user": {"login": "testuser"}, + "created_at": "2024-01-01T12:00:00Z", + "html_url": "https://github.com/test/repo/issues/1#issuecomment-123", }, - event="issue_comment", + issue={"number": 1}, + repository={"owner": {"login": "testowner"}, "name": "testrepo"}, delivery_id="123", ) @@ -592,7 +573,9 @@ def test_handler(event, *args, **kwargs): assert captured_mention.scope.name == "ISSUE" - def test_multiple_mentions_mention(self, test_router, get_mock_github_api): + def test_multiple_mentions_mention( + self, test_router, get_mock_github_api, create_event + ): handler_called = False captured_mention = None @@ -602,19 +585,17 @@ def deploy_handler(event, *args, **kwargs): handler_called = True captured_mention = kwargs.get("context") - event = sansio.Event( - { - "action": "created", - "comment": { - "body": "@bot help\n@bot deploy production", - "user": {"login": "testuser"}, - "created_at": "2024-01-01T12:00:00Z", - "html_url": "https://github.com/test/repo/issues/2#issuecomment-456", - }, - "issue": {"number": 2}, - "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, + event = create_event( + "issue_comment", + action="created", + comment={ + "body": "@bot help\n@bot deploy production", + "user": {"login": "testuser"}, + "created_at": "2024-01-01T12:00:00Z", + "html_url": "https://github.com/test/repo/issues/2#issuecomment-456", }, - event="issue_comment", + issue={"number": 2}, + repository={"owner": {"login": "testowner"}, "name": "testrepo"}, delivery_id="456", ) @@ -633,7 +614,9 @@ def deploy_handler(event, *args, **kwargs): assert first_mention.next_mention is second_mention assert second_mention.previous_mention is first_mention - def test_mention_without_pattern(self, test_router, get_mock_github_api): + def test_mention_without_pattern( + self, test_router, get_mock_github_api, create_event + ): handler_called = False captured_mention = None @@ -643,19 +626,17 @@ def general_handler(event, *args, **kwargs): handler_called = True captured_mention = kwargs.get("context") - event = sansio.Event( - { - "action": "created", - "comment": { - "body": "@bot can you help me?", - "user": {"login": "testuser"}, - "created_at": "2024-01-01T12:00:00Z", - "html_url": "https://github.com/test/repo/issues/3#issuecomment-789", - }, - "issue": {"number": 3}, - "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, + event = create_event( + "issue_comment", + action="created", + comment={ + "body": "@bot can you help me?", + "user": {"login": "testuser"}, + "created_at": "2024-01-01T12:00:00Z", + "html_url": "https://github.com/test/repo/issues/3#issuecomment-789", }, - event="issue_comment", + issue={"number": 3}, + repository={"owner": {"login": "testowner"}, "name": "testrepo"}, delivery_id="789", ) @@ -668,7 +649,7 @@ def general_handler(event, *args, **kwargs): @pytest.mark.asyncio async def test_async_mention_context_structure( - self, test_router, aget_mock_github_api + self, test_router, aget_mock_github_api, create_event ): handler_called = False captured_mention = None @@ -679,19 +660,17 @@ async def async_handler(event, *args, **kwargs): handler_called = True captured_mention = kwargs.get("context") - event = sansio.Event( - { - "action": "created", - "comment": { - "body": "@bot async-test now", - "user": {"login": "asyncuser"}, - "created_at": "2024-01-01T13:00:00Z", - "html_url": "https://github.com/test/repo/issues/4#issuecomment-999", - }, - "issue": {"number": 4}, - "repository": {"owner": {"login": "testowner"}, "name": "testrepo"}, + event = create_event( + "issue_comment", + action="created", + comment={ + "body": "@bot async-test now", + "user": {"login": "asyncuser"}, + "created_at": "2024-01-01T13:00:00Z", + "html_url": "https://github.com/test/repo/issues/4#issuecomment-999", }, - event="issue_comment", + issue={"number": 4}, + repository={"owner": {"login": "testowner"}, "name": "testrepo"}, delivery_id="999", ) @@ -704,7 +683,9 @@ async def async_handler(event, *args, **kwargs): class TestFlexibleMentionTriggers: - def test_pattern_parameter_string(self, test_router, get_mock_github_api): + def test_pattern_parameter_string( + self, test_router, get_mock_github_api, create_event + ): handler_called = False captured_mention = None @@ -714,17 +695,15 @@ def deploy_handler(event, *args, **kwargs): handler_called = True captured_mention = kwargs.get("context") - event = sansio.Event( - { - "action": "created", - "comment": { - "body": "@bot deploy production", - "user": {"login": "user"}, - }, - "issue": {"number": 1}, - "repository": {"owner": {"login": "owner"}, "name": "repo"}, + event = create_event( + "issue_comment", + action="created", + comment={ + "body": "@bot deploy production", + "user": {"login": "user"}, }, - event="issue_comment", + issue={"number": 1}, + repository={"owner": {"login": "owner"}, "name": "repo"}, delivery_id="1", ) mock_gh = get_mock_github_api({}) @@ -741,7 +720,9 @@ def deploy_handler(event, *args, **kwargs): assert not handler_called - def test_pattern_parameter_regex(self, test_router, get_mock_github_api): + def test_pattern_parameter_regex( + self, test_router, get_mock_github_api, create_event + ): handler_called = False captured_mention = None @@ -751,14 +732,12 @@ def deploy_env_handler(event, *args, **kwargs): handler_called = True captured_mention = kwargs.get("context") - event = sansio.Event( - { - "action": "created", - "comment": {"body": "@bot deploy-staging", "user": {"login": "user"}}, - "issue": {"number": 1}, - "repository": {"owner": {"login": "owner"}, "name": "repo"}, - }, - event="issue_comment", + event = create_event( + "issue_comment", + action="created", + comment={"body": "@bot deploy-staging", "user": {"login": "user"}}, + issue={"number": 1}, + repository={"owner": {"login": "owner"}, "name": "repo"}, delivery_id="1", ) mock_gh = get_mock_github_api({}) @@ -768,7 +747,9 @@ def deploy_env_handler(event, *args, **kwargs): assert captured_mention.mention.match is not None assert captured_mention.mention.match.group("env") == "staging" - def test_username_parameter_exact(self, test_router, get_mock_github_api): + def test_username_parameter_exact( + self, test_router, get_mock_github_api, create_event + ): handler_called = False @test_router.mention(username="deploy-bot") @@ -777,14 +758,12 @@ def deploy_bot_handler(event, *args, **kwargs): handler_called = True # Should match deploy-bot - event = sansio.Event( - { - "action": "created", - "comment": {"body": "@deploy-bot run tests", "user": {"login": "user"}}, - "issue": {"number": 1}, - "repository": {"owner": {"login": "owner"}, "name": "repo"}, - }, - event="issue_comment", + event = create_event( + "issue_comment", + action="created", + comment={"body": "@deploy-bot run tests", "user": {"login": "user"}}, + issue={"number": 1}, + repository={"owner": {"login": "owner"}, "name": "repo"}, delivery_id="1", ) mock_gh = get_mock_github_api({}) @@ -799,7 +778,9 @@ def deploy_bot_handler(event, *args, **kwargs): assert not handler_called - def test_username_parameter_regex(self, test_router, get_mock_github_api): + def test_username_parameter_regex( + self, test_router, get_mock_github_api, create_event + ): handler_count = 0 @test_router.mention(username=re.compile(r".*-bot")) @@ -807,17 +788,15 @@ def any_bot_handler(event, *args, **kwargs): nonlocal handler_count handler_count += 1 - event = sansio.Event( - { - "action": "created", - "comment": { - "body": "@deploy-bot start @test-bot check @user help", - "user": {"login": "user"}, - }, - "issue": {"number": 1}, - "repository": {"owner": {"login": "owner"}, "name": "repo"}, + event = create_event( + "issue_comment", + action="created", + comment={ + "body": "@deploy-bot start @test-bot check @user help", + "user": {"login": "user"}, }, - event="issue_comment", + issue={"number": 1}, + repository={"owner": {"login": "owner"}, "name": "repo"}, delivery_id="1", ) mock_gh = get_mock_github_api({}) @@ -826,7 +805,9 @@ def any_bot_handler(event, *args, **kwargs): # Should be called twice (deploy-bot and test-bot) assert handler_count == 2 - def test_username_all_mentions(self, test_router, get_mock_github_api): + def test_username_all_mentions( + self, test_router, get_mock_github_api, create_event + ): mentions_seen = [] @test_router.mention(username=re.compile(r".*")) @@ -834,17 +815,15 @@ def all_mentions_handler(event, *args, **kwargs): mention = kwargs.get("context") mentions_seen.append(mention.mention.username) - event = sansio.Event( - { - "action": "created", - "comment": { - "body": "@alice review @bob deploy @charlie test", - "user": {"login": "user"}, - }, - "issue": {"number": 1}, - "repository": {"owner": {"login": "owner"}, "name": "repo"}, + event = create_event( + "issue_comment", + action="created", + comment={ + "body": "@alice review @bob deploy @charlie test", + "user": {"login": "user"}, }, - event="issue_comment", + issue={"number": 1}, + repository={"owner": {"login": "owner"}, "name": "repo"}, delivery_id="1", ) mock_gh = get_mock_github_api({}) @@ -852,7 +831,7 @@ def all_mentions_handler(event, *args, **kwargs): assert mentions_seen == ["alice", "bob", "charlie"] - def test_combined_filters(self, test_router, get_mock_github_api): + def test_combined_filters(self, test_router, get_mock_github_api, create_event): calls = [] @test_router.mention( @@ -864,14 +843,12 @@ def restricted_deploy(event, *args, **kwargs): calls.append(kwargs) def make_event(body): - return sansio.Event( - { - "action": "created", - "comment": {"body": body, "user": {"login": "user"}}, - "issue": {"number": 1, "pull_request": {"url": "..."}}, - "repository": {"owner": {"login": "owner"}, "name": "repo"}, - }, - event="issue_comment", + return create_event( + "issue_comment", + action="created", + comment={"body": body, "user": {"login": "user"}}, + issue={"number": 1, "pull_request": {"url": "..."}}, + repository={"owner": {"login": "owner"}, "name": "repo"}, delivery_id="1", ) @@ -898,17 +875,15 @@ def make_event(body): # Wrong scope (issue instead of PR) calls.clear() - event4 = sansio.Event( - { - "action": "created", - "comment": { - "body": "@deploy-bot deploy now", - "user": {"login": "user"}, - }, - "issue": {"number": 1}, # No pull_request field - "repository": {"owner": {"login": "owner"}, "name": "repo"}, + event4 = create_event( + "issue_comment", + action="created", + comment={ + "body": "@deploy-bot deploy now", + "user": {"login": "user"}, }, - event="issue_comment", + issue={"number": 1}, # No pull_request field + repository={"owner": {"login": "owner"}, "name": "repo"}, delivery_id="1", ) test_router.dispatch(event4, mock_gh) @@ -916,7 +891,7 @@ def make_event(body): assert len(calls) == 0 def test_multiple_decorators_different_patterns( - self, test_router, get_mock_github_api + self, test_router, get_mock_github_api, create_event ): patterns_matched = [] @@ -927,14 +902,12 @@ def deploy_handler(event, *args, **kwargs): mention = kwargs.get("context") patterns_matched.append(mention.mention.text.split()[0]) - event = sansio.Event( - { - "action": "created", - "comment": {"body": "@bot ship it", "user": {"login": "user"}}, - "issue": {"number": 1}, - "repository": {"owner": {"login": "owner"}, "name": "repo"}, - }, - event="issue_comment", + event = create_event( + "issue_comment", + action="created", + comment={"body": "@bot ship it", "user": {"login": "user"}}, + issue={"number": 1}, + repository={"owner": {"login": "owner"}, "name": "repo"}, delivery_id="1", ) mock_gh = get_mock_github_api({}) @@ -942,7 +915,7 @@ def deploy_handler(event, *args, **kwargs): assert patterns_matched == ["ship"] - def test_question_pattern(self, test_router, get_mock_github_api): + def test_question_pattern(self, test_router, get_mock_github_api, create_event): questions_received = [] @test_router.mention(pattern=re.compile(r".*\?$")) @@ -950,17 +923,15 @@ def question_handler(event, *args, **kwargs): mention = kwargs.get("context") questions_received.append(mention.mention.text) - event = sansio.Event( - { - "action": "created", - "comment": { - "body": "@bot what is the status?", - "user": {"login": "user"}, - }, - "issue": {"number": 1}, - "repository": {"owner": {"login": "owner"}, "name": "repo"}, + event = create_event( + "issue_comment", + action="created", + comment={ + "body": "@bot what is the status?", + "user": {"login": "user"}, }, - event="issue_comment", + issue={"number": 1}, + repository={"owner": {"login": "owner"}, "name": "repo"}, delivery_id="1", ) mock_gh = get_mock_github_api({}) From cbcf4114fa5a36837dadbd66843dcc948116080f Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 19 Jun 2025 18:20:52 -0500 Subject: [PATCH 23/28] Refactor tests to use faker and reduce manual field setting --- tests/conftest.py | 15 +++- tests/test_routing.py | 170 +++++++----------------------------------- 2 files changed, 40 insertions(+), 145 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 4eb7eac..ac4e816 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -277,16 +277,23 @@ def _create_event(event_type, delivery_id=None, **data): if event_type == "issue_comment" and "comment" not in data: data["comment"] = {"body": faker.sentence()} - if "comment" in data and isinstance(data["comment"], str): - # Allow passing just the comment body as a string - data["comment"] = {"body": data["comment"]} - if "comment" in data and "user" not in data["comment"]: data["comment"]["user"] = {"login": faker.user_name()} + if event_type == "issue_comment" and "issue" not in data: + data["issue"] = {"number": faker.random_int(min=1, max=1000)} + + if event_type == "commit_comment" and "commit" not in data: + data["commit"] = {"sha": faker.sha1()} + + if event_type == "pull_request_review_comment" and "pull_request" not in data: + data["pull_request"] = {"number": faker.random_int(min=1, max=1000)} + if "repository" not in data and event_type in [ "issue_comment", "pull_request", + "pull_request_review_comment", + "commit_comment", "push", ]: data["repository"] = { diff --git a/tests/test_routing.py b/tests/test_routing.py index 18ed42d..0caa4ad 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -137,10 +137,7 @@ def handle_mention(event, *args, **kwargs): event = create_event( "issue_comment", action="created", - comment={"body": "@bot hello", "user": {"login": "testuser"}}, - issue={"number": 1}, - repository={"owner": {"login": "testowner"}, "name": "testrepo"}, - delivery_id="123", + comment={"body": "@bot hello"}, ) mock_gh = get_mock_github_api({}) test_router.dispatch(event, mock_gh) @@ -160,10 +157,7 @@ def help_handler(event, *args, **kwargs): event = create_event( "issue_comment", action="created", - comment={"body": "@bot help", "user": {"login": "testuser"}}, - issue={"number": 2}, - repository={"owner": {"login": "testowner"}, "name": "testrepo"}, - delivery_id="123", + comment={"body": "@bot help"}, ) mock_gh = get_mock_github_api({}) test_router.dispatch(event, mock_gh) @@ -183,10 +177,7 @@ def deploy_handler(event, *args, **kwargs): pr_event = create_event( "pull_request_review_comment", action="created", - comment={"body": "@bot deploy", "user": {"login": "testuser"}}, - pull_request={"number": 3}, - repository={"owner": {"login": "testowner"}, "name": "testrepo"}, - delivery_id="123", + comment={"body": "@bot deploy"}, ) test_router.dispatch(pr_event, mock_gh) @@ -195,9 +186,7 @@ def deploy_handler(event, *args, **kwargs): issue_event = create_event( "commit_comment", # This is NOT a PR event action="created", - comment={"body": "@bot deploy", "user": {"login": "testuser"}}, - repository={"owner": {"login": "testowner"}, "name": "testrepo"}, - delivery_id="124", + comment={"body": "@bot deploy"}, ) pr_handler_called = False # Reset @@ -218,10 +207,7 @@ def help_handler(event, *args, **kwargs): event = create_event( "issue_comment", action="created", - comment={"body": "@bot help", "user": {"login": "testuser"}}, - issue={"number": 4}, - repository={"owner": {"login": "testowner"}, "name": "testrepo"}, - delivery_id="123", + comment={"body": "@bot help"}, ) mock_gh = get_mock_github_api({}) test_router.dispatch(event, mock_gh) @@ -247,13 +233,7 @@ def help_handler(event, *args, **kwargs): event = create_event( "issue_comment", action="created", - comment={ - "body": f"@bot {pattern}", - "user": {"login": "testuser"}, - }, - issue={"number": 5}, - repository={"owner": {"login": "testowner"}, "name": "testrepo"}, - delivery_id=f"123-{pattern}", + comment={"body": f"@bot {pattern}"}, ) mock_gh = get_mock_github_api({}) test_router.dispatch(event, mock_gh) @@ -280,10 +260,7 @@ async def async_handler(event, *args, **kwargs): event = create_event( "issue_comment", action="created", - comment={"body": "@bot async-test", "user": {"login": "testuser"}}, - issue={"number": 1}, - repository={"owner": {"login": "testowner"}, "name": "testrepo"}, - delivery_id="123", + comment={"body": "@bot async-test"}, ) mock_gh = aget_mock_github_api({}) @@ -303,10 +280,7 @@ def sync_handler(event, *args, **kwargs): event = create_event( "issue_comment", action="created", - comment={"body": "@bot sync-test", "user": {"login": "testuser"}}, - issue={"number": 6}, - repository={"owner": {"login": "testowner"}, "name": "testrepo"}, - delivery_id="123", + comment={"body": "@bot sync-test"}, ) mock_gh = get_mock_github_api({}) test_router.dispatch(event, mock_gh) @@ -326,10 +300,7 @@ def issue_handler(event, *args, **kwargs): event = create_event( "issue_comment", action="created", - issue={"title": "Bug report", "number": 123}, - comment={"body": "@bot issue-only", "user": {"login": "testuser"}}, - repository={"owner": {"login": "testowner"}, "name": "testrepo"}, - delivery_id="123", + comment={"body": "@bot issue-only"}, ) mock_gh = get_mock_github_api({}) test_router.dispatch(event, mock_gh) @@ -351,13 +322,9 @@ def issue_handler(event, *args, **kwargs): "issue_comment", action="created", issue={ - "title": "PR title", - "number": 456, "pull_request": {"url": "https://api.github.com/..."}, }, - comment={"body": "@bot issue-only", "user": {"login": "testuser"}}, - repository={"owner": {"login": "testowner"}, "name": "testrepo"}, - delivery_id="123", + comment={"body": "@bot issue-only"}, ) mock_gh = get_mock_github_api({}) test_router.dispatch(event, mock_gh) @@ -378,13 +345,9 @@ def pr_handler(event, *args, **kwargs): "issue_comment", action="created", issue={ - "title": "PR title", - "number": 456, "pull_request": {"url": "https://api.github.com/..."}, }, - comment={"body": "@bot pr-only", "user": {"login": "testuser"}}, - repository={"owner": {"login": "testowner"}, "name": "testrepo"}, - delivery_id="123", + comment={"body": "@bot pr-only"}, ) mock_gh = get_mock_github_api({}) test_router.dispatch(event, mock_gh) @@ -404,10 +367,7 @@ def pr_handler(event, *args, **kwargs): event = create_event( "issue_comment", action="created", - issue={"title": "Bug report", "number": 123}, - comment={"body": "@bot pr-only", "user": {"login": "testuser"}}, - repository={"owner": {"login": "testowner"}, "name": "testrepo"}, - delivery_id="123", + comment={"body": "@bot pr-only"}, ) mock_gh = get_mock_github_api({}) test_router.dispatch(event, mock_gh) @@ -428,10 +388,7 @@ def commit_handler(event, *args, **kwargs): event = create_event( "commit_comment", action="created", - comment={"body": "@bot commit-only", "user": {"login": "testuser"}}, - commit={"sha": "abc123"}, - repository={"owner": {"login": "testowner"}, "name": "testrepo"}, - delivery_id="123", + comment={"body": "@bot commit-only"}, ) mock_gh = get_mock_github_api({}) test_router.dispatch(event, mock_gh) @@ -453,10 +410,7 @@ def all_handler(event, *args, **kwargs): event = create_event( "issue_comment", action="created", - issue={"title": "Issue", "number": 1}, - comment={"body": "@bot all-contexts", "user": {"login": "testuser"}}, - repository={"owner": {"login": "testowner"}, "name": "testrepo"}, - delivery_id="123", + comment={"body": "@bot all-contexts"}, ) test_router.dispatch(event, mock_gh) @@ -464,23 +418,16 @@ def all_handler(event, *args, **kwargs): "issue_comment", action="created", issue={ - "title": "PR", - "number": 2, "pull_request": {"url": "..."}, }, - comment={"body": "@bot all-contexts", "user": {"login": "testuser"}}, - repository={"owner": {"login": "testowner"}, "name": "testrepo"}, - delivery_id="124", + comment={"body": "@bot all-contexts"}, ) test_router.dispatch(event, mock_gh) event = create_event( "commit_comment", action="created", - comment={"body": "@bot all-contexts", "user": {"login": "testuser"}}, - commit={"sha": "abc123"}, - repository={"owner": {"login": "testowner"}, "name": "testrepo"}, - delivery_id="125", + comment={"body": "@bot all-contexts"}, ) test_router.dispatch(event, mock_gh) @@ -501,15 +448,12 @@ def deploy_handler(event, *args, **kwargs): event = create_event( "issue_comment", action="created", - comment={"body": "@bot deploy", "user": {"login": "dev"}}, + comment={"body": "@bot deploy"}, issue={ - "number": 42, "pull_request": { "url": "https://api.github.com/repos/test/repo/pulls/42" }, }, - repository={"owner": {"login": "testowner"}, "name": "testrepo"}, - delivery_id="999", ) mock_gh = get_mock_github_api({}) @@ -543,13 +487,9 @@ def test_handler(event, *args, **kwargs): action="created", comment={ "body": "@bot test", - "user": {"login": "testuser"}, "created_at": "2024-01-01T12:00:00Z", "html_url": "https://github.com/test/repo/issues/1#issuecomment-123", }, - issue={"number": 1}, - repository={"owner": {"login": "testowner"}, "name": "testrepo"}, - delivery_id="123", ) mock_gh = get_mock_github_api({}) @@ -560,7 +500,7 @@ def test_handler(event, *args, **kwargs): comment = captured_mention.comment assert comment.body == "@bot test" - assert comment.author == "testuser" + assert comment.author is not None # Generated by faker assert comment.url == "https://github.com/test/repo/issues/1#issuecomment-123" assert len(comment.mentions) == 1 @@ -590,13 +530,9 @@ def deploy_handler(event, *args, **kwargs): action="created", comment={ "body": "@bot help\n@bot deploy production", - "user": {"login": "testuser"}, "created_at": "2024-01-01T12:00:00Z", "html_url": "https://github.com/test/repo/issues/2#issuecomment-456", }, - issue={"number": 2}, - repository={"owner": {"login": "testowner"}, "name": "testrepo"}, - delivery_id="456", ) mock_gh = get_mock_github_api({}) @@ -631,13 +567,9 @@ def general_handler(event, *args, **kwargs): action="created", comment={ "body": "@bot can you help me?", - "user": {"login": "testuser"}, "created_at": "2024-01-01T12:00:00Z", "html_url": "https://github.com/test/repo/issues/3#issuecomment-789", }, - issue={"number": 3}, - repository={"owner": {"login": "testowner"}, "name": "testrepo"}, - delivery_id="789", ) mock_gh = get_mock_github_api({}) @@ -665,13 +597,9 @@ async def async_handler(event, *args, **kwargs): action="created", comment={ "body": "@bot async-test now", - "user": {"login": "asyncuser"}, "created_at": "2024-01-01T13:00:00Z", "html_url": "https://github.com/test/repo/issues/4#issuecomment-999", }, - issue={"number": 4}, - repository={"owner": {"login": "testowner"}, "name": "testrepo"}, - delivery_id="999", ) mock_gh = aget_mock_github_api({}) @@ -698,13 +626,7 @@ def deploy_handler(event, *args, **kwargs): event = create_event( "issue_comment", action="created", - comment={ - "body": "@bot deploy production", - "user": {"login": "user"}, - }, - issue={"number": 1}, - repository={"owner": {"login": "owner"}, "name": "repo"}, - delivery_id="1", + comment={"body": "@bot deploy production"}, ) mock_gh = get_mock_github_api({}) test_router.dispatch(event, mock_gh) @@ -735,10 +657,7 @@ def deploy_env_handler(event, *args, **kwargs): event = create_event( "issue_comment", action="created", - comment={"body": "@bot deploy-staging", "user": {"login": "user"}}, - issue={"number": 1}, - repository={"owner": {"login": "owner"}, "name": "repo"}, - delivery_id="1", + comment={"body": "@bot deploy-staging"}, ) mock_gh = get_mock_github_api({}) test_router.dispatch(event, mock_gh) @@ -761,10 +680,7 @@ def deploy_bot_handler(event, *args, **kwargs): event = create_event( "issue_comment", action="created", - comment={"body": "@deploy-bot run tests", "user": {"login": "user"}}, - issue={"number": 1}, - repository={"owner": {"login": "owner"}, "name": "repo"}, - delivery_id="1", + comment={"body": "@deploy-bot run tests"}, ) mock_gh = get_mock_github_api({}) test_router.dispatch(event, mock_gh) @@ -791,13 +707,7 @@ def any_bot_handler(event, *args, **kwargs): event = create_event( "issue_comment", action="created", - comment={ - "body": "@deploy-bot start @test-bot check @user help", - "user": {"login": "user"}, - }, - issue={"number": 1}, - repository={"owner": {"login": "owner"}, "name": "repo"}, - delivery_id="1", + comment={"body": "@deploy-bot start @test-bot check @user help"}, ) mock_gh = get_mock_github_api({}) test_router.dispatch(event, mock_gh) @@ -818,13 +728,7 @@ def all_mentions_handler(event, *args, **kwargs): event = create_event( "issue_comment", action="created", - comment={ - "body": "@alice review @bob deploy @charlie test", - "user": {"login": "user"}, - }, - issue={"number": 1}, - repository={"owner": {"login": "owner"}, "name": "repo"}, - delivery_id="1", + comment={"body": "@alice review @bob deploy @charlie test"}, ) mock_gh = get_mock_github_api({}) test_router.dispatch(event, mock_gh) @@ -846,10 +750,8 @@ def make_event(body): return create_event( "issue_comment", action="created", - comment={"body": body, "user": {"login": "user"}}, - issue={"number": 1, "pull_request": {"url": "..."}}, - repository={"owner": {"login": "owner"}, "name": "repo"}, - delivery_id="1", + comment={"body": body}, + issue={"pull_request": {"url": "..."}}, ) # All conditions met @@ -878,13 +780,8 @@ def make_event(body): event4 = create_event( "issue_comment", action="created", - comment={ - "body": "@deploy-bot deploy now", - "user": {"login": "user"}, - }, - issue={"number": 1}, # No pull_request field - repository={"owner": {"login": "owner"}, "name": "repo"}, - delivery_id="1", + comment={"body": "@deploy-bot deploy now"}, + issue={}, # No pull_request field ) test_router.dispatch(event4, mock_gh) @@ -905,10 +802,7 @@ def deploy_handler(event, *args, **kwargs): event = create_event( "issue_comment", action="created", - comment={"body": "@bot ship it", "user": {"login": "user"}}, - issue={"number": 1}, - repository={"owner": {"login": "owner"}, "name": "repo"}, - delivery_id="1", + comment={"body": "@bot ship it"}, ) mock_gh = get_mock_github_api({}) test_router.dispatch(event, mock_gh) @@ -926,13 +820,7 @@ def question_handler(event, *args, **kwargs): event = create_event( "issue_comment", action="created", - comment={ - "body": "@bot what is the status?", - "user": {"login": "user"}, - }, - issue={"number": 1}, - repository={"owner": {"login": "owner"}, "name": "repo"}, - delivery_id="1", + comment={"body": "@bot what is the status?"}, ) mock_gh = get_mock_github_api({}) test_router.dispatch(event, mock_gh) From ae7c12efc0c27b4458f145e80c7521406200fe00 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 19 Jun 2025 18:28:23 -0500 Subject: [PATCH 24/28] Remove unused mention handler attributes from routing decorator --- src/django_github_app/routing.py | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/django_github_app/routing.py b/src/django_github_app/routing.py index dee7df9..9f152d0 100644 --- a/src/django_github_app/routing.py +++ b/src/django_github_app/routing.py @@ -26,19 +26,13 @@ CB = TypeVar("CB", AsyncCallback, SyncCallback) -class MentionHandlerBase(Protocol): - _mention_pattern: str | re.Pattern[str] | None - _mention_scope: MentionScope | None - _mention_username: str | re.Pattern[str] | None - - -class AsyncMentionHandler(MentionHandlerBase, Protocol): +class AsyncMentionHandler(Protocol): async def __call__( self, event: sansio.Event, *args: Any, **kwargs: Any ) -> None: ... -class SyncMentionHandler(MentionHandlerBase, Protocol): +class SyncMentionHandler(Protocol): def __call__(self, event: sansio.Event, *args: Any, **kwargs: Any) -> None: ... @@ -103,10 +97,6 @@ def sync_wrapper( else: wrapper = cast(SyncMentionHandler, sync_wrapper) - wrapper._mention_pattern = pattern - wrapper._mention_scope = scope - wrapper._mention_username = username - events = scope.get_events() if scope else MentionScope.all_events() for event_action in events: self.add( From 5aeb035f55a05b1cad1d4808bb17d1e82b4a8beb Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 19 Jun 2025 18:30:28 -0500 Subject: [PATCH 25/28] Clean up comments and formatting --- src/django_github_app/mentions.py | 2 -- src/django_github_app/routing.py | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/django_github_app/mentions.py b/src/django_github_app/mentions.py index a194d7f..1ba76fc 100644 --- a/src/django_github_app/mentions.py +++ b/src/django_github_app/mentions.py @@ -165,7 +165,6 @@ def extract_mentions_from_event( if not comment: return [] - # If no pattern specified, use github app name from settings if username_pattern is None: username_pattern = app_settings.SLUG @@ -190,7 +189,6 @@ def extract_mentions_from_event( ) ) - # link mentions for i, mention in enumerate(mentions): if i > 0: mention.previous_mention = mentions[i - 1] diff --git a/src/django_github_app/routing.py b/src/django_github_app/routing.py index 9f152d0..14fead9 100644 --- a/src/django_github_app/routing.py +++ b/src/django_github_app/routing.py @@ -50,7 +50,7 @@ def __init__(self, *args) -> None: def add( self, func: AsyncCallback | SyncCallback, event_type: str, **data_detail: Any ) -> None: - """Override to accept both async and sync callbacks.""" + # Override to accept both async and sync callbacks. super().add(cast(AsyncCallback, func), event_type, **data_detail) @classproperty From 5c0142703d36c954e279a5bbf90f18b07cf5be99 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 20 Jun 2025 14:35:47 -0500 Subject: [PATCH 26/28] adjust and refactor test suite for mentions --- tests/conftest.py | 12 +- tests/test_mentions.py | 1554 ++++++++++++++++++++++++++-------------- 2 files changed, 1023 insertions(+), 543 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index ac4e816..983f2cf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -274,12 +274,22 @@ def _create_event(event_type, delivery_id=None, **data): if delivery_id is None: delivery_id = seq.next() - if event_type == "issue_comment" and "comment" not in data: + # Auto-create comment field for comment events + if event_type in ["issue_comment", "pull_request_review_comment", "commit_comment"] and "comment" not in data: data["comment"] = {"body": faker.sentence()} + # Auto-create review field for pull request review events + if event_type == "pull_request_review" and "review" not in data: + data["review"] = {"body": faker.sentence()} + + # Add user to comment if not present if "comment" in data and "user" not in data["comment"]: data["comment"]["user"] = {"login": faker.user_name()} + # Add user to review if not present + if "review" in data and "user" not in data["review"]: + data["review"]["user"] = {"login": faker.user_name()} + if event_type == "issue_comment" and "issue" not in data: data["issue"] = {"number": faker.random_int(min=1, max=1000)} diff --git a/tests/test_mentions.py b/tests/test_mentions.py index e3e0877..db7d17e 100644 --- a/tests/test_mentions.py +++ b/tests/test_mentions.py @@ -6,12 +6,17 @@ import pytest from django.test import override_settings from django.utils import timezone -from gidgethub import sansio from django_github_app.mentions import Comment +from django_github_app.mentions import LineInfo +from django_github_app.mentions import Mention from django_github_app.mentions import MentionScope +from django_github_app.mentions import RawMention +from django_github_app.mentions import extract_all_mentions +from django_github_app.mentions import extract_mention_text from django_github_app.mentions import extract_mentions_from_event from django_github_app.mentions import get_match +from django_github_app.mentions import matches_pattern @pytest.fixture(autouse=True) @@ -20,370 +25,330 @@ def setup_test_app_name(override_app_settings): yield -class TestParseMentions: - def test_simple_mention_with_command(self, create_event): - event = create_event("issue_comment", comment="@mybot help") - mentions = extract_mentions_from_event(event, "mybot") - - assert len(mentions) == 1 - assert mentions[0].username == "mybot" - assert mentions[0].text == "help" - assert mentions[0].position == 0 - assert mentions[0].line_info.lineno == 1 - - def test_mention_without_command(self, create_event): - event = create_event("issue_comment", comment="@mybot") - mentions = extract_mentions_from_event(event, "mybot") - - assert len(mentions) == 1 - assert mentions[0].username == "mybot" - assert mentions[0].text == "" - - def test_case_insensitive_matching(self, create_event): - event = create_event("issue_comment", comment="@MyBot help") - mentions = extract_mentions_from_event(event, "mybot") - - assert len(mentions) == 1 - assert mentions[0].username == "MyBot" # Username is preserved as found - assert mentions[0].text == "help" - - def test_command_case_normalization(self, create_event): - event = create_event("issue_comment", comment="@mybot HELP") - mentions = extract_mentions_from_event(event, "mybot") +class TestExtractAllMentions: + @pytest.mark.parametrize( + "text,expected_mentions", + [ + # Valid usernames + ("@validuser", [("validuser", 0, 10)]), + ("@Valid-User-123", [("Valid-User-123", 0, 15)]), + ("@123startswithnumber", [("123startswithnumber", 0, 20)]), + # Multiple mentions + ( + "@alice review @bob help @charlie test", + [("alice", 0, 6), ("bob", 14, 18), ("charlie", 24, 32)], + ), + # Invalid patterns - partial extraction + ("@-invalid", []), # Can't start with hyphen + ("@invalid-", [("invalid", 0, 8)]), # Hyphen at end not included + ("@in--valid", [("in", 0, 3)]), # Stops at double hyphen + # Long username - truncated to 39 chars + ( + "@toolongusernamethatexceedsthirtyninecharacters", + [("toolongusernamethatexceedsthirtyninecha", 0, 40)], + ), + # Special blocks tested in test_preserves_positions_with_special_blocks + # Edge cases + ("@", []), # Just @ symbol + ("@@double", []), # Double @ symbol + ("email@example.com", []), # Email (not at start of word) + ("@123", [("123", 0, 4)]), # Numbers only + ("@user_name", [("user", 0, 5)]), # Underscore stops extraction + ("test@user", []), # Not at word boundary + ("@user@another", [("user", 0, 5)]), # Second @ not at boundary + ], + ) + def test_extract_all_mentions(self, text, expected_mentions): + mentions = extract_all_mentions(text) - assert len(mentions) == 1 - # Command case is preserved in text, normalization happens elsewhere - assert mentions[0].text == "HELP" + assert len(mentions) == len(expected_mentions) + for i, (username, start, end) in enumerate(expected_mentions): + assert mentions[i].username == username + assert mentions[i].position == start + assert mentions[i].end == end - def test_multiple_mentions(self, create_event): - event = create_event( - "issue_comment", comment="@mybot help and then @mybot deploy" - ) - mentions = extract_mentions_from_event(event, "mybot") - - assert len(mentions) == 2 - assert mentions[0].text == "help and then" - assert mentions[1].text == "deploy" + @pytest.mark.parametrize( + "text,expected_mentions", + [ + # Code block with triple backticks + ( + "Before code\n```\n@codebot ignored\n```\n@realbot after", + [("realbot", 37, 45)], + ), + # Inline code with single backticks + ( + "Use `@inlinebot command` here, but @realbot works", + [("realbot", 35, 43)], + ), + # Blockquote with > + ( + "> @quotedbot ignored\n@realbot visible", + [("realbot", 21, 29)], + ), + # Multiple code blocks + ( + "```\n@bot1\n```\nMiddle @bot2\n```\n@bot3\n```\nEnd @bot4", + [("bot2", 21, 26), ("bot4", 45, 50)], + ), + # Nested backticks in code block + ( + "```\n`@nestedbot`\n```\n@realbot after", + [("realbot", 21, 29)], + ), + # Multiple inline codes + ( + "`@bot1` and `@bot2` but @bot3 and @bot4", + [("bot3", 24, 29), ("bot4", 34, 39)], + ), + # Mixed special blocks + ( + "Start\n```\n@codebot\n```\n`@inline` text\n> @quoted line\n@realbot end", + [("realbot", 53, 61)], + ), + # Empty code block + ( + "Before\n```\n\n```\n@realbot after", + [("realbot", 16, 24)], + ), + # Code block at start + ( + "```\n@ignored\n```\n@realbot only", + [("realbot", 17, 25)], + ), + # Multiple blockquotes + ( + "> @bot1 quoted\n> @bot2 also quoted\n@bot3 not quoted", + [("bot3", 35, 40)], + ), + ], + ) + def test_preserves_positions_with_special_blocks(self, text, expected_mentions): + mentions = extract_all_mentions(text) - def test_ignore_other_mentions(self, create_event): - event = create_event( - "issue_comment", comment="@otheruser help @mybot deploy @someone else" - ) - mentions = extract_mentions_from_event(event, "mybot") + assert len(mentions) == len(expected_mentions) + for i, (username, start, end) in enumerate(expected_mentions): + assert mentions[i].username == username + assert mentions[i].position == start + assert mentions[i].end == end + # Verify positions are preserved despite replacements + assert text[mentions[i].position : mentions[i].end] == f"@{username}" - assert len(mentions) == 1 - assert mentions[0].text == "deploy" - - def test_mention_in_code_block(self, create_event): - text = """ - Here's some text - ``` - @mybot help - ``` - @mybot deploy - """ - event = create_event("issue_comment", comment=text) - mentions = extract_mentions_from_event(event, "mybot") - assert len(mentions) == 1 - assert mentions[0].text == "deploy" +class TestExtractMentionsFromEvent: + @pytest.mark.parametrize( + "body,username_pattern,expected", + [ + # Simple mention with command + ( + "@mybot help", + "mybot", + [{"username": "mybot", "text": "help"}], + ), + # Mention without command + ("@mybot", "mybot", [{"username": "mybot", "text": ""}]), + # Case insensitive matching - preserves original case + ("@MyBot help", "mybot", [{"username": "MyBot", "text": "help"}]), + # Command case preserved + ("@mybot HELP", "mybot", [{"username": "mybot", "text": "HELP"}]), + # Mention in middle + ("Hey @mybot help me", "mybot", [{"username": "mybot", "text": "help me"}]), + # With punctuation + ("@mybot help!", "mybot", [{"username": "mybot", "text": "help!"}]), + # No space after mention + ( + "@mybot, please help", + "mybot", + [{"username": "mybot", "text": ", please help"}], + ), + # Multiple spaces before command + ("@mybot help", "mybot", [{"username": "mybot", "text": "help"}]), + # Hyphenated command + ( + "@mybot async-test", + "mybot", + [{"username": "mybot", "text": "async-test"}], + ), + # Special character command + ("@mybot ?", "mybot", [{"username": "mybot", "text": "?"}]), + # Hyphenated username matches pattern + ("@my-bot help", "my-bot", [{"username": "my-bot", "text": "help"}]), + # Username with underscore - doesn't match pattern + ("@my_bot help", "my_bot", []), + # Empty text + ("", "mybot", []), + ], + ) + def test_mention_extraction_scenarios( + self, body, username_pattern, expected, create_event + ): + event = create_event("issue_comment", comment={"body": body} if body else {}) - def test_mention_in_inline_code(self, create_event): - event = create_event( - "issue_comment", comment="Use `@mybot help` for help, or just @mybot deploy" - ) - mentions = extract_mentions_from_event(event, "mybot") + mentions = extract_mentions_from_event(event, username_pattern) - assert len(mentions) == 1 - assert mentions[0].text == "deploy" - - def test_mention_in_quote(self, create_event): - text = """ - > @mybot help - @mybot deploy - """ - event = create_event("issue_comment", comment=text) - mentions = extract_mentions_from_event(event, "mybot") + assert len(mentions) == len(expected) + for i, exp in enumerate(expected): + assert mentions[i].username == exp["username"] + assert mentions[i].text == exp["text"] - assert len(mentions) == 1 - assert mentions[0].text == "deploy" + @pytest.mark.parametrize( + "body,bot_pattern,expected", + [ + # Multiple mentions of same bot + ( + "@mybot help and then @mybot deploy", + "mybot", + [{"text": "help and then"}, {"text": "deploy"}], + ), + # Ignore other mentions + ( + "@otheruser help @mybot deploy @someone else", + "mybot", + [{"text": "deploy"}], + ), + ], + ) + def test_multiple_and_filtered_mentions( + self, body, bot_pattern, expected, create_event + ): + event = create_event("issue_comment", comment={"body": body}) - def test_empty_text(self, create_event): - event = create_event("issue_comment", comment="") - mentions = extract_mentions_from_event(event, "mybot") + mentions = extract_mentions_from_event(event, bot_pattern) - assert mentions == [] + assert len(mentions) == len(expected) + for i, exp in enumerate(expected): + assert mentions[i].text == exp["text"] - def test_none_text(self, create_event): - # Create an event with no comment body + def test_missing_comment_body(self, create_event): event = create_event("issue_comment") - mentions = extract_mentions_from_event(event, "mybot") - - assert mentions == [] - def test_mention_at_start_of_line(self, create_event): - event = create_event("issue_comment", comment="@mybot help") mentions = extract_mentions_from_event(event, "mybot") - assert len(mentions) == 1 - assert mentions[0].text == "help" - - def test_mention_in_middle_of_text(self, create_event): - event = create_event("issue_comment", comment="Hey @mybot help me") - mentions = extract_mentions_from_event(event, "mybot") - - assert len(mentions) == 1 - assert mentions[0].text == "help me" - - def test_mention_with_punctuation_after(self, create_event): - event = create_event("issue_comment", comment="@mybot help!") - mentions = extract_mentions_from_event(event, "mybot") - - assert len(mentions) == 1 - assert mentions[0].text == "help!" - - def test_hyphenated_username(self, create_event): - event = create_event("issue_comment", comment="@my-bot help") - mentions = extract_mentions_from_event(event, "my-bot") - - assert len(mentions) == 1 - assert mentions[0].username == "my-bot" - assert mentions[0].text == "help" - - def test_underscore_username(self, create_event): - # GitHub usernames don't support underscores - event = create_event("issue_comment", comment="@my_bot help") - mentions = extract_mentions_from_event(event, "my_bot") - - assert len(mentions) == 0 # Should not match invalid username - - def test_no_space_after_mention(self, create_event): - event = create_event("issue_comment", comment="@mybot, please help") - mentions = extract_mentions_from_event(event, "mybot") - - assert len(mentions) == 1 - assert mentions[0].text == ", please help" - - def test_multiple_spaces_before_command(self, create_event): - event = create_event("issue_comment", comment="@mybot help") - mentions = extract_mentions_from_event(event, "mybot") - - assert len(mentions) == 1 - assert mentions[0].text == "help" # Whitespace is stripped - - def test_hyphenated_command(self, create_event): - event = create_event("issue_comment", comment="@mybot async-test") - mentions = extract_mentions_from_event(event, "mybot") - - assert len(mentions) == 1 - assert mentions[0].text == "async-test" - - def test_special_character_command(self, create_event): - event = create_event("issue_comment", comment="@mybot ?") - mentions = extract_mentions_from_event(event, "mybot") - - assert len(mentions) == 1 - assert mentions[0].text == "?" - + assert mentions == [] -class TestGetEventScope: - def test_from_event_for_various_events(self): - event1 = sansio.Event({"issue": {}}, event="issue_comment", delivery_id="1") - assert MentionScope.from_event(event1) == MentionScope.ISSUE + @pytest.mark.parametrize( + "body,bot_pattern,expected_mentions", + [ + # Default pattern (None uses "bot" from test settings) + ("@bot help @otherbot test", None, [("bot", "help")]), + # Specific bot name + ( + "@bot help @deploy-bot test @test-bot check", + "deploy-bot", + [("deploy-bot", "test")], + ), + ], + ) + def test_extract_mentions_from_event_patterns( + self, body, bot_pattern, expected_mentions, create_event + ): + event = create_event("issue_comment", comment={"body": body}) - event2 = sansio.Event({}, event="pull_request_review_comment", delivery_id="2") - assert MentionScope.from_event(event2) == MentionScope.PR + mentions = extract_mentions_from_event(event, bot_pattern) - event3 = sansio.Event({}, event="commit_comment", delivery_id="3") - assert MentionScope.from_event(event3) == MentionScope.COMMIT + assert len(mentions) == len(expected_mentions) + for i, (username, text) in enumerate(expected_mentions): + assert mentions[i].username == username + assert mentions[i].text == text - def test_issue_scope_on_issue_comment(self): - issue_event = sansio.Event( - {"issue": {"title": "Bug report"}}, event="issue_comment", delivery_id="1" + def test_mention_linking(self, create_event): + event = create_event( + "issue_comment", + comment={"body": "@bot1 first @bot2 second @bot3 third"}, ) - assert MentionScope.from_event(issue_event) == MentionScope.ISSUE - pr_event = sansio.Event( - {"issue": {"title": "PR title", "pull_request": {"url": "..."}}}, - event="issue_comment", - delivery_id="2", - ) - assert MentionScope.from_event(pr_event) == MentionScope.PR + mentions = extract_mentions_from_event(event, re.compile(r"bot\d")) - def test_pr_scope_on_issue_comment(self): - issue_event = sansio.Event( - {"issue": {"title": "Bug report"}}, event="issue_comment", delivery_id="1" - ) - assert MentionScope.from_event(issue_event) == MentionScope.ISSUE + assert len(mentions) == 3 + # First mention + assert mentions[0].previous_mention is None + assert mentions[0].next_mention is mentions[1] + # Second mention + assert mentions[1].previous_mention is mentions[0] + assert mentions[1].next_mention is mentions[2] + # Third mention + assert mentions[2].previous_mention is mentions[1] + assert mentions[2].next_mention is None - pr_event = sansio.Event( - {"issue": {"title": "PR title", "pull_request": {"url": "..."}}}, - event="issue_comment", - delivery_id="2", + def test_mention_text_extraction_stops_at_next_mention(self, create_event): + event = create_event( + "issue_comment", + comment={"body": "@bot1 first command @bot2 second command @bot3 third"}, ) - assert MentionScope.from_event(pr_event) == MentionScope.PR - - def test_pr_scope_allows_pr_specific_events(self): - event1 = sansio.Event({}, event="pull_request_review_comment", delivery_id="1") - assert MentionScope.from_event(event1) == MentionScope.PR - - event2 = sansio.Event({}, event="pull_request_review", delivery_id="2") - assert MentionScope.from_event(event2) == MentionScope.PR - - event3 = sansio.Event({}, event="commit_comment", delivery_id="3") - assert MentionScope.from_event(event3) == MentionScope.COMMIT - - def test_commit_scope_allows_commit_comment_only(self): - event1 = sansio.Event({}, event="commit_comment", delivery_id="1") - assert MentionScope.from_event(event1) == MentionScope.COMMIT - - event2 = sansio.Event({"issue": {}}, event="issue_comment", delivery_id="2") - assert MentionScope.from_event(event2) == MentionScope.ISSUE - event3 = sansio.Event({}, event="pull_request_review_comment", delivery_id="3") - assert MentionScope.from_event(event3) == MentionScope.PR + mentions = extract_mentions_from_event(event, re.compile(r"bot[123]")) - def test_different_event_types_have_correct_scope(self): - event1 = sansio.Event({}, event="pull_request_review_comment", delivery_id="1") - assert MentionScope.from_event(event1) == MentionScope.PR - - event2 = sansio.Event({}, event="commit_comment", delivery_id="2") - assert MentionScope.from_event(event2) == MentionScope.COMMIT + assert len(mentions) == 3 + assert mentions[0].username == "bot1" + assert mentions[0].text == "first command" + assert mentions[1].username == "bot2" + assert mentions[1].text == "second command" + assert mentions[2].username == "bot3" + assert mentions[2].text == "third" - def test_pull_request_field_none_treated_as_issue(self): - event = sansio.Event( - {"issue": {"title": "Issue", "pull_request": None}}, - event="issue_comment", - delivery_id="1", - ) - assert MentionScope.from_event(event) == MentionScope.ISSUE - def test_missing_issue_data(self): - event = sansio.Event({}, event="issue_comment", delivery_id="1") - assert MentionScope.from_event(event) == MentionScope.ISSUE +class TestMentionScope: + @pytest.mark.parametrize( + "event_type,data,expected", + [ + ("issue_comment", {}, MentionScope.ISSUE), + ( + "issue_comment", + {"issue": {"pull_request": {"url": "..."}}}, + MentionScope.PR, + ), + ("issue_comment", {"issue": {"pull_request": None}}, MentionScope.ISSUE), + ("pull_request_review", {}, MentionScope.PR), + ("pull_request_review_comment", {}, MentionScope.PR), + ("commit_comment", {}, MentionScope.COMMIT), + ("unknown_event", {}, None), + ], + ) + def test_from_event(self, event_type, data, expected, create_event): + event = create_event(event_type=event_type, **data) - def test_unknown_event_returns_none(self): - event = sansio.Event({}, event="unknown_event", delivery_id="1") - assert MentionScope.from_event(event) is None + assert MentionScope.from_event(event) == expected class TestComment: - def test_from_event_issue_comment(self): - event = sansio.Event( - { - "comment": { - "body": "This is a test comment", - "user": {"login": "testuser"}, - "created_at": "2024-01-01T12:00:00Z", - "html_url": "https://github.com/test/repo/issues/1#issuecomment-123", - } - }, - event="issue_comment", - delivery_id="test-1", - ) + @pytest.mark.parametrize( + "event_type", + [ + "issue_comment", + "pull_request_review_comment", + "pull_request_review", + "commit_comment", + ], + ) + def test_from_event(self, event_type, create_event): + event = create_event(event_type) comment = Comment.from_event(event) - assert comment.body == "This is a test comment" - assert comment.author == "testuser" - assert comment.created_at.isoformat() == "2024-01-01T12:00:00+00:00" - assert comment.url == "https://github.com/test/repo/issues/1#issuecomment-123" + assert isinstance(comment.body, str) + assert isinstance(comment.author, str) + assert comment.created_at is not None + assert isinstance(comment.url, str) assert comment.mentions == [] - assert comment.line_count == 1 - - def test_from_event_pull_request_review_comment(self): - event = sansio.Event( - { - "comment": { - "body": "Line 1\nLine 2\nLine 3", - "user": {"login": "reviewer"}, - "created_at": "2024-02-15T14:30:00Z", - "html_url": "https://github.com/test/repo/pull/5#discussion_r123", - } - }, - event="pull_request_review_comment", - delivery_id="test-2", - ) - - comment = Comment.from_event(event) - - assert comment.body == "Line 1\nLine 2\nLine 3" - assert comment.author == "reviewer" - assert comment.url == "https://github.com/test/repo/pull/5#discussion_r123" - assert comment.line_count == 3 - - def test_from_event_pull_request_review(self): - event = sansio.Event( - { - "review": { - "body": "LGTM!", - "user": {"login": "approver"}, - "created_at": "2024-03-10T09:15:00Z", - "html_url": "https://github.com/test/repo/pull/10#pullrequestreview-123", - } - }, - event="pull_request_review", - delivery_id="test-3", - ) - - comment = Comment.from_event(event) - - assert comment.body == "LGTM!" - assert comment.author == "approver" - assert ( - comment.url == "https://github.com/test/repo/pull/10#pullrequestreview-123" - ) - - def test_from_event_commit_comment(self): - event = sansio.Event( - { - "comment": { - "body": "Nice commit!", - "user": {"login": "commenter"}, - "created_at": "2024-04-20T16:45:00Z", - "html_url": "https://github.com/test/repo/commit/abc123#commitcomment-456", - } - }, - event="commit_comment", - delivery_id="test-4", - ) - - comment = Comment.from_event(event) - - assert comment.body == "Nice commit!" - assert comment.author == "commenter" - assert ( - comment.url - == "https://github.com/test/repo/commit/abc123#commitcomment-456" - ) + assert isinstance(comment.line_count, int) - def test_from_event_missing_fields(self): - event = sansio.Event( - { - "comment": { - "body": "Minimal comment", - # Missing user, created_at, html_url - }, - "sender": {"login": "fallback-user"}, + def test_from_event_missing_fields(self, create_event): + event = create_event( + "issue_comment", + comment={ + "user": {}, # Empty with no login to test fallback }, - event="issue_comment", - delivery_id="test-5", + sender={"login": "fallback-user"}, ) comment = Comment.from_event(event) - assert comment.body == "Minimal comment" assert comment.author == "fallback-user" assert comment.url == "" # created_at should be roughly now assert (timezone.now() - comment.created_at).total_seconds() < 5 - def test_from_event_invalid_event_type(self): - event = sansio.Event( - {"some_data": "value"}, - event="push", - delivery_id="test-6", - ) + def test_from_event_invalid_event_type(self, create_event): + event = create_event("push", some_data="value") with pytest.raises( ValueError, match="Cannot extract comment from event type: push" @@ -409,219 +374,156 @@ def test_line_count_property(self, body, line_count): ) assert comment.line_count == line_count - def test_from_event_timezone_handling(self): - event = sansio.Event( - { - "comment": { - "body": "Test", - "user": {"login": "user"}, - "created_at": "2024-01-01T12:00:00Z", - "html_url": "", - } - }, - event="issue_comment", - delivery_id="test-7", - ) - - comment = Comment.from_event(event) - - # Check that the datetime is timezone-aware (UTC) - assert comment.created_at.tzinfo is not None - assert comment.created_at.isoformat() == "2024-01-01T12:00:00+00:00" - - def test_from_event_timezone_handling_use_tz_false(self): - event = sansio.Event( - { - "comment": { - "body": "Test", - "user": {"login": "user"}, - "created_at": "2024-01-01T12:00:00Z", - "html_url": "", - } - }, - event="issue_comment", - delivery_id="test-7", + @pytest.mark.parametrize( + "USE_TZ,created_at,expected", + [ + (True, "2024-01-01T12:00:00Z", "2024-01-01T12:00:00+00:00"), + (False, "2024-01-01T12:00:00Z", "2024-01-01T12:00:00"), + ], + ) + def test_from_event_timezone_handling( + self, USE_TZ, created_at, expected, create_event + ): + event = create_event( + "issue_comment", + comment={"created_at": created_at}, ) - with override_settings(USE_TZ=False, TIME_ZONE="UTC"): + with override_settings(USE_TZ=USE_TZ, TIME_ZONE="UTC"): comment = Comment.from_event(event) - # Check that the datetime is naive (no timezone info) - assert comment.created_at.tzinfo is None - # When USE_TZ=False with TIME_ZONE="UTC", the naive datetime should match the original UTC time - assert comment.created_at.isoformat() == "2024-01-01T12:00:00" + assert comment.created_at.isoformat() == expected -class TestPatternMatching: - def test_get_match_none(self): - match = get_match("any text", None) - - assert match is not None - assert match.group(0) == "any text" - - def test_get_match_literal_string(self): - # Matching case - match = get_match("deploy production", "deploy") - assert match is not None - assert match.group(0) == "deploy" - - # Case insensitive - match = get_match("DEPLOY production", "deploy") - assert match is not None +class TestGetMatch: + @pytest.mark.parametrize( + "text,pattern,should_match,expected", + [ + # Literal string matching + ("deploy production", "deploy", True, "deploy"), + # Case insensitive - matches but preserves original case + ("DEPLOY production", "deploy", True, "DEPLOY"), + # No match + ("help me", "deploy", False, None), + # Must start with pattern + ("please deploy", "deploy", False, None), + ], + ) + def test_get_match_literal_string(self, text, pattern, should_match, expected): + match = get_match(text, pattern) - # No match - match = get_match("help me", "deploy") - assert match is None + if should_match: + assert match is not None + assert match.group(0) == expected + else: + assert match is None - # Must start with pattern - match = get_match("please deploy", "deploy") - assert match is None + @pytest.mark.parametrize( + "text,pattern,expected_groups", + [ + # Simple regex with capture group + ( + "deploy prod", + re.compile(r"deploy (prod|staging)"), + {0: "deploy prod", 1: "prod"}, + ), + # Named groups + ( + "deploy-prod", + re.compile(r"deploy-(?Pprod|staging|dev)"), + {0: "deploy-prod", "env": "prod"}, + ), + # Question mark pattern + ( + "can you help?", + re.compile(r".*\?$"), + {0: "can you help?"}, + ), + # No match + ( + "deploy test", + re.compile(r"deploy (prod|staging)"), + None, + ), + ], + ) + def test_get_match_regex(self, text, pattern, expected_groups): + match = get_match(text, pattern) - def test_get_match_regex(self): - # Simple regex - match = get_match("deploy prod", re.compile(r"deploy (prod|staging)")) - assert match is not None - assert match.group(0) == "deploy prod" - assert match.group(1) == "prod" + if expected_groups is None: + assert match is None + else: + assert match is not None + for group_key, expected_value in expected_groups.items(): + assert match.group(group_key) == expected_value - # Named groups - match = get_match( - "deploy-prod", re.compile(r"deploy-(?Pprod|staging|dev)") - ) - assert match is not None - assert match.group("env") == "prod" + def test_get_match_none(self): + match = get_match("any text", None) - # Question mark pattern - match = get_match("can you help?", re.compile(r".*\?$")) assert match is not None + assert match.group(0) == "any text" - # No match - match = get_match("deploy test", re.compile(r"deploy (prod|staging)")) - assert match is None - - def test_get_match_invalid_regex(self): - # Invalid regex should be treated as literal - match = get_match("test [invalid", "[invalid") - assert match is None # Doesn't start with [invalid - - match = get_match("[invalid regex", "[invalid") - assert match is not None # Starts with literal [invalid - - def test_get_match_flag_preservation(self): - # Case-sensitive pattern - pattern_cs = re.compile(r"DEPLOY", re.MULTILINE) - match = get_match("deploy", pattern_cs) - assert match is None # Should not match due to case sensitivity - - # Case-insensitive pattern - pattern_ci = re.compile(r"DEPLOY", re.IGNORECASE) - match = get_match("deploy", pattern_ci) - - assert match is not None # Should match - - # Multiline pattern - pattern_ml = re.compile(r"^prod$", re.MULTILINE) - match = get_match("staging\nprod\ndev", pattern_ml) - - assert match is None # Pattern expects exact match from start - - def test_extract_mentions_from_event_default(self): - event = sansio.Event( - {"comment": {"body": "@bot help @otherbot test"}}, - event="issue_comment", - delivery_id="test", - ) - - mentions = extract_mentions_from_event(event, None) # Uses default "bot" - - assert len(mentions) == 1 - assert mentions[0].username == "bot" - assert mentions[0].text == "help" - - def test_extract_mentions_from_event_specific(self): - event = sansio.Event( - {"comment": {"body": "@bot help @deploy-bot test @test-bot check"}}, - event="issue_comment", - delivery_id="test", - ) - - mentions = extract_mentions_from_event(event, "deploy-bot") - - assert len(mentions) == 1 - assert mentions[0].username == "deploy-bot" - assert mentions[0].text == "test" - - def test_extract_mentions_from_event_regex(self): - event = sansio.Event( - { - "comment": { - "body": "@bot help @deploy-bot test @test-bot check @user ignore" - } - }, - event="issue_comment", - delivery_id="test", - ) - - mentions = extract_mentions_from_event(event, re.compile(r".*-bot")) - - assert len(mentions) == 2 - assert mentions[0].username == "deploy-bot" - assert mentions[0].text == "test" - assert mentions[1].username == "test-bot" - assert mentions[1].text == "check" - - assert mentions[0].next_mention is mentions[1] - assert mentions[1].previous_mention is mentions[0] + @pytest.mark.parametrize( + "text,pattern,should_match", + [ + # Invalid regex treated as literal - doesn't start with [invalid + ("test [invalid", "[invalid", False), + # Invalid regex treated as literal - starts with [invalid + ("[invalid regex", "[invalid", True), + ], + ) + def test_get_match_invalid_regex(self, text, pattern, should_match): + match = get_match(text, pattern) - def test_extract_mentions_from_event_all(self): - event = sansio.Event( - {"comment": {"body": "@alice review @bob help @charlie test"}}, - event="issue_comment", - delivery_id="test", - ) + if should_match: + assert match is not None + else: + assert match is None - mentions = extract_mentions_from_event(event, re.compile(r".*")) + @pytest.mark.parametrize( + "text,pattern,should_match", + [ + # Case-sensitive pattern + ("deploy", re.compile(r"DEPLOY", re.MULTILINE), False), + # Case-insensitive pattern + ("deploy", re.compile(r"DEPLOY", re.IGNORECASE), True), + # Multiline pattern - expects match from start of text + ("staging\nprod\ndev", re.compile(r"^prod$", re.MULTILINE), False), + ], + ) + def test_get_match_flag_preservation(self, text, pattern, should_match): + match = get_match(text, pattern) - assert len(mentions) == 3 - assert mentions[0].username == "alice" - assert mentions[0].text == "review" - assert mentions[1].username == "bob" - assert mentions[1].text == "help" - assert mentions[2].username == "charlie" - assert mentions[2].text == "test" + if should_match: + assert match is not None + else: + assert match is None class TestReDoSProtection: - """Test that the ReDoS vulnerability has been fixed.""" - - def test_redos_vulnerability_fixed(self, create_event): - """Test that malicious input doesn't cause catastrophic backtracking.""" - # Create a malicious comment that would cause ReDoS with the old implementation + def test_redos_vulnerability(self, create_event): + # Create a malicious comment that would cause potentially cause ReDoS # Pattern: (bot|ai|assistant)+ matching "botbotbot...x" malicious_username = "bot" * 20 + "x" - event = create_event("issue_comment", comment=f"@{malicious_username} hello") + event = create_event( + "issue_comment", comment={"body": f"@{malicious_username} hello"} + ) - # This pattern would cause catastrophic backtracking in the old implementation pattern = re.compile(r"(bot|ai|assistant)+") - # Measure execution time start_time = time.time() mentions = extract_mentions_from_event(event, pattern) execution_time = time.time() - start_time - # Should complete quickly (under 0.1 seconds) - old implementation would take seconds/minutes assert execution_time < 0.1 # The username gets truncated at 39 chars, and the 'x' is left out # So it will match the pattern, but the important thing is it completes quickly assert len(mentions) == 1 - assert ( - mentions[0].username == "botbotbotbotbotbotbotbotbotbotbotbotbot" - ) # 39 chars + assert mentions[0].username == "botbotbotbotbotbotbotbotbotbotbotbotbot" def test_nested_quantifier_pattern(self, create_event): - """Test patterns with nested quantifiers don't cause issues.""" event = create_event( - "issue_comment", comment="@deploy-bot-bot-bot test command" + "issue_comment", comment={"body": "@deploy-bot-bot-bot test command"} ) # This type of pattern could cause issues: (word)+ @@ -636,8 +538,9 @@ def test_nested_quantifier_pattern(self, create_event): assert len(mentions) == 0 def test_alternation_with_quantifier(self, create_event): - """Test alternation patterns with quantifiers.""" - event = create_event("issue_comment", comment="@mybot123bot456bot789 deploy") + event = create_event( + "issue_comment", comment={"body": "@mybot123bot456bot789 deploy"} + ) # Pattern like (a|b)* that could be dangerous pattern = re.compile(r"(my|bot|[0-9])+") @@ -651,14 +554,14 @@ def test_alternation_with_quantifier(self, create_event): assert len(mentions) == 1 assert mentions[0].username == "mybot123bot456bot789" - def test_complex_regex_patterns_safe(self, create_event): - """Test that complex patterns are handled safely.""" + def test_complex_regex_patterns_handled_safely(self, create_event): event = create_event( "issue_comment", - comment="@test @test-bot @test-bot-123 @testbotbotbot @verylongusername123456789", + comment={ + "body": "@test @test-bot @test-bot-123 @testbotbotbot @verylongusername123456789" + }, ) - # Various potentially problematic patterns patterns = [ re.compile(r".*bot.*"), # Wildcards re.compile(r"test.*"), # Leading wildcard @@ -672,48 +575,12 @@ def test_complex_regex_patterns_safe(self, create_event): extract_mentions_from_event(event, pattern) execution_time = time.time() - start_time - # All patterns should execute quickly assert execution_time < 0.1 - def test_github_username_constraints(self, create_event): - """Test that only valid GitHub usernames are extracted.""" - event = create_event( - "issue_comment", - comment=( - "@validuser @Valid-User-123 @-invalid @invalid- @in--valid " - "@toolongusernamethatexceedsthirtyninecharacters @123startswithnumber" - ), - ) - - mentions = extract_mentions_from_event(event, re.compile(r".*")) - - # Check what usernames were actually extracted - extracted_usernames = [m.username for m in mentions] - - # The regex extracts: - # - validuser (valid) - # - Valid-User-123 (valid) - # - invalid (from @invalid-, hyphen at end not included) - # - in (from @in--valid, stops at double hyphen) - # - toolongusernamethatexceedsthirtyninecha (truncated to 39 chars) - # - 123startswithnumber (valid - GitHub allows starting with numbers) - assert len(mentions) == 6 - assert "validuser" in extracted_usernames - assert "Valid-User-123" in extracted_usernames - # These are extracted but not ideal - the regex follows GitHub's rules - assert "invalid" in extracted_usernames # From @invalid- - assert "in" in extracted_usernames # From @in--valid - assert ( - "toolongusernamethatexceedsthirtyninecha" in extracted_usernames - ) # Truncated - assert "123startswithnumber" in extracted_usernames # Valid GitHub username - def test_performance_with_many_mentions(self, create_event): - """Test performance with many mentions in a single comment.""" - # Create a comment with 100 mentions usernames = [f"@user{i}" for i in range(100)] comment_body = " ".join(usernames) + " Please review all" - event = create_event("issue_comment", comment=comment_body) + event = create_event("issue_comment", comment={"body": comment_body}) pattern = re.compile(r"user\d+") @@ -721,10 +588,613 @@ def test_performance_with_many_mentions(self, create_event): mentions = extract_mentions_from_event(event, pattern) execution_time = time.time() - start_time - # Should handle many mentions efficiently assert execution_time < 0.5 assert len(mentions) == 100 - - # Verify all mentions are correctly parsed for i, mention in enumerate(mentions): assert mention.username == f"user{i}" + + +class TestLineInfo: + @pytest.mark.parametrize( + "comment,position,expected_lineno,expected_text", + [ + # Single line mentions + ("@user hello", 0, 1, "@user hello"), + ("Hey @user how are you?", 4, 1, "Hey @user how are you?"), + ("Thanks @user", 7, 1, "Thanks @user"), + # Multi-line mentions + ( + "@user please review\nthis pull request\nthanks!", + 0, + 1, + "@user please review", + ), + ("Hello there\n@user can you help?\nThanks!", 12, 2, "@user can you help?"), + ("First line\nSecond line\nThanks @user", 31, 3, "Thanks @user"), + # Empty and edge cases + ("", 0, 1, ""), + ( + "Simple comment with @user mention", + 20, + 1, + "Simple comment with @user mention", + ), + # Blank lines + ( + "First line\n\n@user on third line\n\nFifth line", + 12, + 3, + "@user on third line", + ), + ("\n\n\n@user appears here", 3, 4, "@user appears here"), + # Unicode/emoji + ( + "First line 👋\n@user こんにちは 🎉\nThird line", + 14, + 2, + "@user こんにちは 🎉", + ), + ], + ) + def test_for_mention_in_comment( + self, comment, position, expected_lineno, expected_text + ): + line_info = LineInfo.for_mention_in_comment(comment, position) + + assert line_info.lineno == expected_lineno + assert line_info.text == expected_text + + @pytest.mark.parametrize( + "comment,position,expected_lineno,expected_text", + [ + # Trailing newlines should be stripped from line text + ("Hey @user\n", 4, 1, "Hey @user"), + # Position beyond comment length + ("Short", 100, 1, "Short"), + # Unix-style line endings + ("Line 1\n@user line 2", 7, 2, "@user line 2"), + # Windows-style line endings (\r\n handled as single separator) + ("Line 1\r\n@user line 2", 8, 2, "@user line 2"), + ], + ) + def test_edge_cases(self, comment, position, expected_lineno, expected_text): + line_info = LineInfo.for_mention_in_comment(comment, position) + + assert line_info.lineno == expected_lineno + assert line_info.text == expected_text + + @pytest.mark.parametrize( + "comment,position,expected_lineno", + [ + ("Hey @alice and @bob, please review", 4, 1), + ("Hey @alice and @bob, please review", 15, 1), + ], + ) + def test_multiple_mentions_same_line(self, comment, position, expected_lineno): + line_info = LineInfo.for_mention_in_comment(comment, position) + + assert line_info.lineno == expected_lineno + assert line_info.text == comment + + +class TestMatchesPattern: + @pytest.mark.parametrize( + "text,pattern,expected", + [ + # String patterns - exact match (case insensitive) + ("deploy", "deploy", True), + ("DEPLOY", "deploy", True), + ("deploy", "DEPLOY", True), + ("Deploy", "deploy", True), + # String patterns - whitespace handling + (" deploy ", "deploy", True), + ("deploy", " deploy ", True), + (" deploy ", " deploy ", True), + # String patterns - no match + ("deploy prod", "deploy", False), + ("deployment", "deploy", False), + ("redeploy", "deploy", False), + ("help", "deploy", False), + # Empty strings + ("", "", True), + ("deploy", "", False), + ("", "deploy", False), + # Special characters in string patterns + ("deploy-prod", "deploy-prod", True), + ("deploy_prod", "deploy_prod", True), + ("deploy.prod", "deploy.prod", True), + ], + ) + def test_string_pattern_matching(self, text, pattern, expected): + assert matches_pattern(text, pattern) == expected + + @pytest.mark.parametrize( + "text,pattern_str,flags,expected", + [ + # Basic regex patterns + ("deploy", r"deploy", 0, True), + ("deploy prod", r"deploy", 0, False), # fullmatch requires entire string + ("deploy", r".*deploy.*", 0, True), + ("redeploy", r".*deploy.*", 0, True), + # Case sensitivity with regex - moved to test_pattern_flags_preserved + # Complex regex patterns + ("deploy-prod", r"deploy-(prod|staging|dev)", 0, True), + ("deploy-staging", r"deploy-(prod|staging|dev)", 0, True), + ("deploy-test", r"deploy-(prod|staging|dev)", 0, False), + # Anchored patterns (fullmatch behavior) + ("deploy prod", r"^deploy$", 0, False), + ("deploy", r"^deploy$", 0, True), + # Wildcards and quantifiers + ("deploy", r"dep.*", 0, True), + ("deployment", r"deploy.*", 0, True), + ("dep", r"deploy?", 0, False), # fullmatch requires entire string + # Character classes + ("deploy123", r"deploy\d+", 0, True), + ("deploy-abc", r"deploy\d+", 0, False), + # Empty pattern + ("anything", r".*", 0, True), + ("", r".*", 0, True), + # Suffix matching (from removed test) + ("deploy-bot", r".*-bot", 0, True), + ("test-bot", r".*-bot", 0, True), + ("user", r".*-bot", 0, False), + # Prefix with digits (from removed test) + ("mybot1", r"mybot\d+", 0, True), + ("mybot2", r"mybot\d+", 0, True), + ("otherbot", r"mybot\d+", 0, False), + ], + ) + def test_regex_pattern_matching(self, text, pattern_str, flags, expected): + pattern = re.compile(pattern_str, flags) + + assert matches_pattern(text, pattern) == expected + + @pytest.mark.parametrize( + "text,expected", + [ + # re.match would return True for these, but fullmatch returns False + ("deploy prod", False), + ("deployment", False), + # Only exact full matches should return True + ("deploy", True), + ], + ) + def test_regex_fullmatch_vs_match_behavior(self, text, expected): + pattern = re.compile(r"deploy") + + assert matches_pattern(text, pattern) is expected + + @pytest.mark.parametrize( + "text,pattern_str,flags,expected", + [ + # Case insensitive pattern + ("DEPLOY", r"deploy", re.IGNORECASE, True), + ("Deploy", r"deploy", re.IGNORECASE, True), + ("deploy", r"deploy", re.IGNORECASE, True), + # Case sensitive pattern (default) + ("DEPLOY", r"deploy", 0, False), + ("Deploy", r"deploy", 0, False), + ("deploy", r"deploy", 0, True), + # DOTALL flag allows . to match newlines + ("line1\nline2", r"line1.*line2", re.DOTALL, True), + ( + "line1\nline2", + r"line1.*line2", + 0, + False, + ), # Without DOTALL, . doesn't match \n + ("line1 line2", r"line1.*line2", 0, True), + ], + ) + def test_pattern_flags_preserved(self, text, pattern_str, flags, expected): + pattern = re.compile(pattern_str, flags) + + assert matches_pattern(text, pattern) == expected + + +class TestMention: + @pytest.mark.parametrize( + "event_type,event_data,username,pattern,scope,expected_count,expected_mentions", + [ + # Basic mention extraction + ( + "issue_comment", + {"comment": {"body": "@bot help"}}, + "bot", + None, + None, + 1, + [{"username": "bot", "text": "help"}], + ), + # No mentions in event + ( + "issue_comment", + {"comment": {"body": "No mentions here"}}, + None, + None, + None, + 0, + [], + ), + # Multiple mentions, filter by username + ( + "issue_comment", + {"comment": {"body": "@bot1 help @bot2 deploy @user test"}}, + re.compile(r"bot\d"), + None, + None, + 2, + [ + {"username": "bot1", "text": "help"}, + {"username": "bot2", "text": "deploy"}, + ], + ), + # Scope filtering - matching scope + ( + "issue_comment", + {"comment": {"body": "@bot help"}, "issue": {}}, + "bot", + None, + MentionScope.ISSUE, + 1, + [{"username": "bot", "text": "help"}], + ), + # Scope filtering - non-matching scope (PR comment on issue-only scope) + ( + "issue_comment", + {"comment": {"body": "@bot help"}, "issue": {"pull_request": {}}}, + "bot", + None, + MentionScope.ISSUE, + 0, + [], + ), + # Pattern matching on mention text + ( + "issue_comment", + {"comment": {"body": "@bot deploy prod @bot help me"}}, + "bot", + re.compile(r"deploy.*"), + None, + 1, + [{"username": "bot", "text": "deploy prod"}], + ), + # String pattern matching (case insensitive) + ( + "issue_comment", + {"comment": {"body": "@bot DEPLOY @bot help"}}, + "bot", + "deploy", + None, + 1, + [{"username": "bot", "text": "DEPLOY"}], + ), + # No username filter defaults to app name (bot) + ( + "issue_comment", + {"comment": {"body": "@alice review @bot help"}}, + None, + None, + None, + 1, + [{"username": "bot", "text": "help"}], + ), + # Get all mentions with wildcard regex pattern + ( + "issue_comment", + {"comment": {"body": "@alice review @bob help"}}, + re.compile(r".*"), + None, + None, + 2, + [ + {"username": "alice", "text": "review"}, + {"username": "bob", "text": "help"}, + ], + ), + # PR review comment + ( + "pull_request_review_comment", + {"comment": {"body": "@reviewer please check"}}, + "reviewer", + None, + MentionScope.PR, + 1, + [{"username": "reviewer", "text": "please check"}], + ), + # Commit comment + ( + "commit_comment", + {"comment": {"body": "@bot test this commit"}}, + "bot", + None, + MentionScope.COMMIT, + 1, + [{"username": "bot", "text": "test this commit"}], + ), + # Complex filtering: username + pattern + scope + ( + "issue_comment", + { + "comment": { + "body": "@mybot deploy staging @otherbot deploy prod @mybot help" + } + }, + "mybot", + re.compile(r"deploy\s+(staging|prod)"), + None, + 1, + [{"username": "mybot", "text": "deploy staging"}], + ), + # Empty comment body + ( + "issue_comment", + {"comment": {"body": ""}}, + None, + None, + None, + 0, + [], + ), + # Mentions in code blocks (should be ignored) + ( + "issue_comment", + {"comment": {"body": "```\n@bot deploy\n```\n@bot help"}}, + "bot", + None, + None, + 1, + [{"username": "bot", "text": "help"}], + ), + ], + ) + def test_from_event( + self, + create_event, + event_type, + event_data, + username, + pattern, + scope, + expected_count, + expected_mentions, + ): + event = create_event(event_type, **event_data) + + mentions = list( + Mention.from_event(event, username=username, pattern=pattern, scope=scope) + ) + + assert len(mentions) == expected_count + for mention, expected in zip(mentions, expected_mentions, strict=False): + assert isinstance(mention, Mention) + assert mention.mention.username == expected["username"] + assert mention.mention.text == expected["text"] + assert mention.comment.body == event_data["comment"]["body"] + assert mention.scope == MentionScope.from_event(event) + + # Verify match object is set when pattern is provided + if pattern is not None: + assert mention.mention.match is not None + + @pytest.mark.parametrize( + "body,username,pattern,expected_matches", + [ + # Pattern groups are accessible via match object + ( + "@bot deploy prod to server1", + "bot", + re.compile(r"deploy\s+(\w+)\s+to\s+(\w+)"), + [("prod", "server1")], + ), + # Named groups + ( + "@bot deploy staging", + "bot", + re.compile(r"deploy\s+(?Pprod|staging|dev)"), + [{"env": "staging"}], + ), + ], + ) + def test_from_event_pattern_groups( + self, create_event, body, username, pattern, expected_matches + ): + event = create_event("issue_comment", comment={"body": body}) + + mentions = list(Mention.from_event(event, username=username, pattern=pattern)) + + assert len(mentions) == len(expected_matches) + for mention, expected in zip(mentions, expected_matches, strict=False): + assert mention.mention.match is not None + if isinstance(expected, tuple): + assert mention.mention.match.groups() == expected + elif isinstance(expected, dict): + assert mention.mention.match.groupdict() == expected + + +class TestExtractMentionText: + @pytest.fixture + def create_raw_mention(self): + def _create(username: str, position: int, end: int) -> RawMention: + # Create a dummy match object - extract_mention_text doesn't use it + dummy_text = f"@{username}" + match = re.match(r"@(\w+)", dummy_text) + assert match is not None # For type checker + return RawMention( + match=match, username=username, position=position, end=end + ) + + return _create + + @pytest.mark.parametrize( + "body,all_mentions_data,mention_end,expected_text", + [ + # Basic case: text after mention until next mention + ( + "@user1 hello world @user2 goodbye", + [("user1", 0, 6), ("user2", 19, 25)], + 6, + "hello world", + ), + # No text after mention (next mention immediately follows) + ( + "@user1@user2 hello", + [("user1", 0, 6), ("user2", 6, 12)], + 6, + "", + ), + # Empty text between mentions (whitespace only) + ( + "@user1 @user2", + [("user1", 0, 6), ("user2", 9, 15)], + 6, + "", + ), + # Single mention with text + ( + "@user hello world", + [("user", 0, 5)], + 5, + "hello world", + ), + # Mention at end of string + ( + "Hello @user", + [("user", 6, 11)], + 11, + "", + ), + # Multiple spaces and newlines (should be stripped) + ( + "@user1 \n\n hello world \n @user2", + [("user1", 0, 6), ("user2", 28, 34)], + 6, + "hello world", + ), + # Text with special characters + ( + "@bot deploy-prod --force @admin", + [("bot", 0, 4), ("admin", 25, 31)], + 4, + "deploy-prod --force", + ), + # Unicode text + ( + "@user こんにちは 🎉 @other", + [("user", 0, 5), ("other", 14, 20)], + 5, + "こんにちは 🎉", + ), + # Empty body + ( + "", + [], + 0, + "", + ), + # Complex multi-line text + ( + "@user1 Line 1\nLine 2\nLine 3 @user2 End", + [("user1", 0, 6), ("user2", 28, 34)], + 6, + "Line 1\nLine 2\nLine 3", + ), + # Trailing whitespace should be stripped + ( + "@user text with trailing spaces ", + [("user", 0, 5)], + 5, + "text with trailing spaces", + ), + ], + ) + def test_extract_mention_text( + self, create_raw_mention, body, all_mentions_data, mention_end, expected_text + ): + all_mentions = [ + create_raw_mention(username, pos, end) + for username, pos, end in all_mentions_data + ] + + result = extract_mention_text(body, 0, all_mentions, mention_end) + + assert result == expected_text + + @pytest.mark.parametrize( + "body,current_index,all_mentions_data,mention_end,expected_text", + [ + # Last mention: text until end of string + ( + "@user1 hello @user2 goodbye world", + 1, + [("user1", 0, 6), ("user2", 13, 19)], + 19, + "goodbye world", + ), + # Current index is not first mention + ( + "@alice intro @bob middle text @charlie end", + 1, # Looking at @bob + [ + ("alice", 0, 6), + ("bob", 13, 17), + ("charlie", 30, 38), + ], + 17, + "middle text", + ), + # Multiple mentions with different current indices + ( + "@a first @b second @c third @d fourth", + 2, # Looking at @c + [ + ("a", 0, 2), + ("b", 9, 11), + ("c", 19, 21), + ("d", 28, 30), + ], + 21, + "third", + ), + ], + ) + def test_extract_mention_text_with_different_current_index( + self, + create_raw_mention, + body, + current_index, + all_mentions_data, + mention_end, + expected_text, + ): + all_mentions = [ + create_raw_mention(username, pos, end) + for username, pos, end in all_mentions_data + ] + + result = extract_mention_text(body, current_index, all_mentions, mention_end) + + assert result == expected_text + + @pytest.mark.parametrize( + "current_index,expected_text", + [ + # Last mention - should get text until end + (1, "world"), + # Out of bounds current_index (should still work) + (10, "world"), + ], + ) + def test_extract_mention_text_with_invalid_indices( + self, create_raw_mention, current_index, expected_text + ): + all_mentions = [ + create_raw_mention("user1", 0, 6), + create_raw_mention("user2", 13, 19), + ] + + result = extract_mention_text( + "@user1 hello @user2 world", current_index, all_mentions, 19 + ) + + assert result == expected_text From 9fff27c247e2e6c2cfc8fff824fa3f0ed871b041 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 20 Jun 2025 19:38:18 +0000 Subject: [PATCH 27/28] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/conftest.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 983f2cf..7dd57d3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -275,7 +275,11 @@ def _create_event(event_type, delivery_id=None, **data): delivery_id = seq.next() # Auto-create comment field for comment events - if event_type in ["issue_comment", "pull_request_review_comment", "commit_comment"] and "comment" not in data: + if ( + event_type + in ["issue_comment", "pull_request_review_comment", "commit_comment"] + and "comment" not in data + ): data["comment"] = {"body": faker.sentence()} # Auto-create review field for pull request review events From 07e7001c09a61a7f683babbaf96513e544284905 Mon Sep 17 00:00:00 2001 From: Josh Date: Mon, 23 Jun 2025 16:13:25 -0500 Subject: [PATCH 28/28] Simplify mention parsing and remove text extraction --- src/django_github_app/mentions.py | 156 +----- src/django_github_app/routing.py | 13 +- tests/conftest.py | 4 +- tests/test_mentions.py | 639 +++------------------- tests/test_routing.py | 861 ++++++++++-------------------- 5 files changed, 369 insertions(+), 1304 deletions(-) diff --git a/src/django_github_app/mentions.py b/src/django_github_app/mentions.py index 1ba76fc..13321f3 100644 --- a/src/django_github_app/mentions.py +++ b/src/django_github_app/mentions.py @@ -2,16 +2,11 @@ import re from dataclasses import dataclass -from datetime import datetime from enum import Enum from typing import NamedTuple -from django.conf import settings -from django.utils import timezone from gidgethub import sansio -from .conf import app_settings - class EventAction(NamedTuple): event: str @@ -76,8 +71,6 @@ class RawMention: CODE_BLOCK_PATTERN = re.compile(r"```[\s\S]*?```", re.MULTILINE) INLINE_CODE_PATTERN = re.compile(r"`[^`]+`") BLOCKQUOTE_PATTERN = re.compile(r"^\s*>.*$", re.MULTILINE) - - # GitHub username rules: # - 1-39 characters long # - Can only contain alphanumeric characters or hyphens @@ -127,63 +120,47 @@ def for_mention_in_comment(cls, comment: str, mention_position: int): return cls(lineno=line_number, text=line_text) -def extract_mention_text( - body: str, current_index: int, all_mentions: list[RawMention], mention_end: int -) -> str: - text_start = mention_end - - # Find next @mention (any mention, not just matched ones) to know where this text ends - next_mention_index = None - for j in range(current_index + 1, len(all_mentions)): - next_mention_index = j - break - - if next_mention_index is not None: - text_end = all_mentions[next_mention_index].position - else: - text_end = len(body) - - return body[text_start:text_end].strip() - - @dataclass class ParsedMention: username: str - text: str position: int line_info: LineInfo - match: re.Match[str] | None = None previous_mention: ParsedMention | None = None next_mention: ParsedMention | None = None +def matches_pattern(text: str, pattern: str | re.Pattern[str]) -> bool: + match pattern: + case re.Pattern(): + return pattern.fullmatch(text) is not None + case str(): + return text.strip().lower() == pattern.strip().lower() + + def extract_mentions_from_event( event: sansio.Event, username_pattern: str | re.Pattern[str] | None = None ) -> list[ParsedMention]: - comment = event.data.get("comment", {}).get("body", "") + comment_key = "comment" if event.event != "pull_request_review" else "review" + comment = event.data.get(comment_key, {}).get("body", "") if not comment: return [] - if username_pattern is None: - username_pattern = app_settings.SLUG - mentions: list[ParsedMention] = [] potential_mentions = extract_all_mentions(comment) - for i, raw_mention in enumerate(potential_mentions): - if not matches_pattern(raw_mention.username, username_pattern): + for raw_mention in potential_mentions: + if username_pattern and not matches_pattern( + raw_mention.username, username_pattern + ): continue - text = extract_mention_text(comment, i, potential_mentions, raw_mention.end) - line_info = LineInfo.for_mention_in_comment(comment, raw_mention.position) - mentions.append( ParsedMention( username=raw_mention.username, - text=text, position=raw_mention.position, - line_info=line_info, - match=None, + line_info=LineInfo.for_mention_in_comment( + comment, raw_mention.position + ), previous_mention=None, next_mention=None, ) @@ -198,63 +175,8 @@ def extract_mentions_from_event( return mentions -@dataclass -class Comment: - body: str - author: str - created_at: datetime - url: str - mentions: list[ParsedMention] - - @property - def line_count(self) -> int: - if not self.body: - return 0 - return len(self.body.splitlines()) - - @classmethod - def from_event(cls, event: sansio.Event) -> Comment: - match event.event: - case "issue_comment" | "pull_request_review_comment" | "commit_comment": - comment_data = event.data.get("comment") - case "pull_request_review": - comment_data = event.data.get("review") - case _: - comment_data = None - - if not comment_data: - raise ValueError(f"Cannot extract comment from event type: {event.event}") - - if created_at_str := comment_data.get("created_at", ""): - # GitHub timestamps are in ISO format: 2024-01-01T12:00:00Z - created_at_aware = datetime.fromisoformat( - created_at_str.replace("Z", "+00:00") - ) - if settings.USE_TZ: - created_at = created_at_aware - else: - created_at = timezone.make_naive( - created_at_aware, timezone.get_default_timezone() - ) - else: - created_at = timezone.now() - - author = comment_data.get("user", {}).get("login", "") - if not author and "sender" in event.data: - author = event.data.get("sender", {}).get("login", "") - - return cls( - body=comment_data.get("body", ""), - author=author, - created_at=created_at, - url=comment_data.get("html_url", ""), - mentions=[], - ) - - @dataclass class Mention: - comment: Comment mention: ParsedMention scope: MentionScope | None @@ -264,50 +186,8 @@ def from_event( event: sansio.Event, *, username: str | re.Pattern[str] | None = None, - pattern: str | re.Pattern[str] | None = None, scope: MentionScope | None = None, ): - event_scope = MentionScope.from_event(event) - if scope is not None and event_scope != scope: - return - mentions = extract_mentions_from_event(event, username) - if not mentions: - return - - comment = Comment.from_event(event) - comment.mentions = mentions - for mention in mentions: - if pattern is not None: - match = get_match(mention.text, pattern) - if not match: - continue - mention.match = match - - yield cls( - comment=comment, - mention=mention, - scope=event_scope, - ) - - -def matches_pattern(text: str, pattern: str | re.Pattern[str]) -> bool: - match pattern: - case re.Pattern(): - return pattern.fullmatch(text) is not None - case str(): - return text.strip().lower() == pattern.strip().lower() - - -def get_match(text: str, pattern: str | re.Pattern[str] | None) -> re.Match[str] | None: - match pattern: - case None: - return re.match(r"(.*)", text, re.IGNORECASE | re.DOTALL) - case re.Pattern(): - # Use the pattern directly, preserving its flags - return pattern.match(text) - case str(): - # For strings, do exact match (case-insensitive) - # Escape the string to treat it literally - return re.match(re.escape(pattern), text, re.IGNORECASE) + yield cls(mention=mention, scope=scope) diff --git a/src/django_github_app/routing.py b/src/django_github_app/routing.py index 14fead9..68233ae 100644 --- a/src/django_github_app/routing.py +++ b/src/django_github_app/routing.py @@ -67,7 +67,6 @@ def decorator(func: CB) -> CB: def mention( self, *, - pattern: str | re.Pattern[str] | None = None, username: str | re.Pattern[str] | None = None, scope: MentionScope | None = None, **kwargs: Any, @@ -77,8 +76,12 @@ def decorator(func: CB) -> CB: async def async_wrapper( event: sansio.Event, gh: AsyncGitHubAPI, *args: Any, **kwargs: Any ) -> None: + event_scope = MentionScope.from_event(event) + if scope is not None and event_scope != scope: + return + for mention in Mention.from_event( - event, username=username, pattern=pattern, scope=scope + event, username=username, scope=event_scope ): await func(event, gh, *args, context=mention, **kwargs) # type: ignore[func-returns-value] @@ -86,8 +89,12 @@ async def async_wrapper( def sync_wrapper( event: sansio.Event, gh: SyncGitHubAPI, *args: Any, **kwargs: Any ) -> None: + event_scope = MentionScope.from_event(event) + if scope is not None and event_scope != scope: + return + for mention in Mention.from_event( - event, username=username, pattern=pattern, scope=scope + event, username=username, scope=event_scope ): func(event, gh, *args, context=mention, **kwargs) diff --git a/tests/conftest.py b/tests/conftest.py index 7dd57d3..8e6e0cf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -280,11 +280,11 @@ def _create_event(event_type, delivery_id=None, **data): in ["issue_comment", "pull_request_review_comment", "commit_comment"] and "comment" not in data ): - data["comment"] = {"body": faker.sentence()} + data["comment"] = {"body": f"@{faker.user_name()} {faker.sentence()}"} # Auto-create review field for pull request review events if event_type == "pull_request_review" and "review" not in data: - data["review"] = {"body": faker.sentence()} + data["review"] = {"body": f"@{faker.user_name()} {faker.sentence()}"} # Add user to comment if not present if "comment" in data and "user" not in data["comment"]: diff --git a/tests/test_mentions.py b/tests/test_mentions.py index db7d17e..f0587a8 100644 --- a/tests/test_mentions.py +++ b/tests/test_mentions.py @@ -4,18 +4,12 @@ import time import pytest -from django.test import override_settings -from django.utils import timezone -from django_github_app.mentions import Comment from django_github_app.mentions import LineInfo from django_github_app.mentions import Mention from django_github_app.mentions import MentionScope -from django_github_app.mentions import RawMention from django_github_app.mentions import extract_all_mentions -from django_github_app.mentions import extract_mention_text from django_github_app.mentions import extract_mentions_from_event -from django_github_app.mentions import get_match from django_github_app.mentions import matches_pattern @@ -136,109 +130,83 @@ def test_preserves_positions_with_special_blocks(self, text, expected_mentions): class TestExtractMentionsFromEvent: @pytest.mark.parametrize( - "body,username_pattern,expected", + "body,username,expected", [ # Simple mention with command ( "@mybot help", "mybot", - [{"username": "mybot", "text": "help"}], + [{"username": "mybot"}], ), # Mention without command - ("@mybot", "mybot", [{"username": "mybot", "text": ""}]), + ("@mybot", "mybot", [{"username": "mybot"}]), # Case insensitive matching - preserves original case - ("@MyBot help", "mybot", [{"username": "MyBot", "text": "help"}]), + ("@MyBot help", "mybot", [{"username": "MyBot"}]), # Command case preserved - ("@mybot HELP", "mybot", [{"username": "mybot", "text": "HELP"}]), + ("@mybot HELP", "mybot", [{"username": "mybot"}]), # Mention in middle - ("Hey @mybot help me", "mybot", [{"username": "mybot", "text": "help me"}]), + ("Hey @mybot help me", "mybot", [{"username": "mybot"}]), # With punctuation - ("@mybot help!", "mybot", [{"username": "mybot", "text": "help!"}]), + ("@mybot help!", "mybot", [{"username": "mybot"}]), # No space after mention ( "@mybot, please help", "mybot", - [{"username": "mybot", "text": ", please help"}], + [{"username": "mybot"}], ), # Multiple spaces before command - ("@mybot help", "mybot", [{"username": "mybot", "text": "help"}]), + ("@mybot help", "mybot", [{"username": "mybot"}]), # Hyphenated command ( "@mybot async-test", "mybot", - [{"username": "mybot", "text": "async-test"}], + [{"username": "mybot"}], ), # Special character command - ("@mybot ?", "mybot", [{"username": "mybot", "text": "?"}]), + ("@mybot ?", "mybot", [{"username": "mybot"}]), # Hyphenated username matches pattern - ("@my-bot help", "my-bot", [{"username": "my-bot", "text": "help"}]), + ("@my-bot help", "my-bot", [{"username": "my-bot"}]), # Username with underscore - doesn't match pattern ("@my_bot help", "my_bot", []), # Empty text ("", "mybot", []), ], ) - def test_mention_extraction_scenarios( - self, body, username_pattern, expected, create_event - ): + def test_mention_extraction_scenarios(self, body, username, expected, create_event): event = create_event("issue_comment", comment={"body": body} if body else {}) - mentions = extract_mentions_from_event(event, username_pattern) + mentions = extract_mentions_from_event(event, username) assert len(mentions) == len(expected) for i, exp in enumerate(expected): assert mentions[i].username == exp["username"] - assert mentions[i].text == exp["text"] @pytest.mark.parametrize( - "body,bot_pattern,expected", + "body,bot_pattern,expected_mentions", [ # Multiple mentions of same bot ( "@mybot help and then @mybot deploy", "mybot", - [{"text": "help and then"}, {"text": "deploy"}], + ["mybot", "mybot"], ), - # Ignore other mentions + # Filter specific mentions, ignore others ( "@otheruser help @mybot deploy @someone else", "mybot", - [{"text": "deploy"}], + ["mybot"], ), - ], - ) - def test_multiple_and_filtered_mentions( - self, body, bot_pattern, expected, create_event - ): - event = create_event("issue_comment", comment={"body": body}) - - mentions = extract_mentions_from_event(event, bot_pattern) - - assert len(mentions) == len(expected) - for i, exp in enumerate(expected): - assert mentions[i].text == exp["text"] - - def test_missing_comment_body(self, create_event): - event = create_event("issue_comment") - - mentions = extract_mentions_from_event(event, "mybot") - - assert mentions == [] - - @pytest.mark.parametrize( - "body,bot_pattern,expected_mentions", - [ - # Default pattern (None uses "bot" from test settings) - ("@bot help @otherbot test", None, [("bot", "help")]), - # Specific bot name + # Default pattern (None matches all mentions) + ("@bot help @otherbot test", None, ["bot", "otherbot"]), + # Specific bot name pattern ( "@bot help @deploy-bot test @test-bot check", "deploy-bot", - [("deploy-bot", "test")], + ["deploy-bot"], ), ], ) - def test_extract_mentions_from_event_patterns( + def test_mention_filtering_and_patterns( self, body, bot_pattern, expected_mentions, create_event ): event = create_event("issue_comment", comment={"body": body}) @@ -246,9 +214,15 @@ def test_extract_mentions_from_event_patterns( mentions = extract_mentions_from_event(event, bot_pattern) assert len(mentions) == len(expected_mentions) - for i, (username, text) in enumerate(expected_mentions): + for i, username in enumerate(expected_mentions): assert mentions[i].username == username - assert mentions[i].text == text + + def test_missing_comment_body(self, create_event): + event = create_event("issue_comment") + + mentions = extract_mentions_from_event(event, "mybot") + + assert mentions == [] def test_mention_linking(self, create_event): event = create_event( @@ -259,31 +233,19 @@ def test_mention_linking(self, create_event): mentions = extract_mentions_from_event(event, re.compile(r"bot\d")) assert len(mentions) == 3 - # First mention - assert mentions[0].previous_mention is None - assert mentions[0].next_mention is mentions[1] - # Second mention - assert mentions[1].previous_mention is mentions[0] - assert mentions[1].next_mention is mentions[2] - # Third mention - assert mentions[2].previous_mention is mentions[1] - assert mentions[2].next_mention is None - - def test_mention_text_extraction_stops_at_next_mention(self, create_event): - event = create_event( - "issue_comment", - comment={"body": "@bot1 first command @bot2 second command @bot3 third"}, - ) - mentions = extract_mentions_from_event(event, re.compile(r"bot[123]")) + first = mentions[0] + second = mentions[1] + third = mentions[2] - assert len(mentions) == 3 - assert mentions[0].username == "bot1" - assert mentions[0].text == "first command" - assert mentions[1].username == "bot2" - assert mentions[1].text == "second command" - assert mentions[2].username == "bot3" - assert mentions[2].text == "third" + assert first.previous_mention is None + assert first.next_mention is second + + assert second.previous_mention is first + assert second.next_mention is third + + assert third.previous_mention is second + assert third.next_mention is None class TestMentionScope: @@ -309,197 +271,6 @@ def test_from_event(self, event_type, data, expected, create_event): assert MentionScope.from_event(event) == expected -class TestComment: - @pytest.mark.parametrize( - "event_type", - [ - "issue_comment", - "pull_request_review_comment", - "pull_request_review", - "commit_comment", - ], - ) - def test_from_event(self, event_type, create_event): - event = create_event(event_type) - - comment = Comment.from_event(event) - - assert isinstance(comment.body, str) - assert isinstance(comment.author, str) - assert comment.created_at is not None - assert isinstance(comment.url, str) - assert comment.mentions == [] - assert isinstance(comment.line_count, int) - - def test_from_event_missing_fields(self, create_event): - event = create_event( - "issue_comment", - comment={ - "user": {}, # Empty with no login to test fallback - }, - sender={"login": "fallback-user"}, - ) - - comment = Comment.from_event(event) - - assert comment.author == "fallback-user" - assert comment.url == "" - # created_at should be roughly now - assert (timezone.now() - comment.created_at).total_seconds() < 5 - - def test_from_event_invalid_event_type(self, create_event): - event = create_event("push", some_data="value") - - with pytest.raises( - ValueError, match="Cannot extract comment from event type: push" - ): - Comment.from_event(event) - - @pytest.mark.parametrize( - "body,line_count", - [ - ("Single line", 1), - ("Line 1\nLine 2\nLine 3", 3), - ("Line 1\n\nLine 3", 3), - ("", 0), - ], - ) - def test_line_count_property(self, body, line_count): - comment = Comment( - body=body, - author="user", - created_at=timezone.now(), - url="", - mentions=[], - ) - assert comment.line_count == line_count - - @pytest.mark.parametrize( - "USE_TZ,created_at,expected", - [ - (True, "2024-01-01T12:00:00Z", "2024-01-01T12:00:00+00:00"), - (False, "2024-01-01T12:00:00Z", "2024-01-01T12:00:00"), - ], - ) - def test_from_event_timezone_handling( - self, USE_TZ, created_at, expected, create_event - ): - event = create_event( - "issue_comment", - comment={"created_at": created_at}, - ) - - with override_settings(USE_TZ=USE_TZ, TIME_ZONE="UTC"): - comment = Comment.from_event(event) - - assert comment.created_at.isoformat() == expected - - -class TestGetMatch: - @pytest.mark.parametrize( - "text,pattern,should_match,expected", - [ - # Literal string matching - ("deploy production", "deploy", True, "deploy"), - # Case insensitive - matches but preserves original case - ("DEPLOY production", "deploy", True, "DEPLOY"), - # No match - ("help me", "deploy", False, None), - # Must start with pattern - ("please deploy", "deploy", False, None), - ], - ) - def test_get_match_literal_string(self, text, pattern, should_match, expected): - match = get_match(text, pattern) - - if should_match: - assert match is not None - assert match.group(0) == expected - else: - assert match is None - - @pytest.mark.parametrize( - "text,pattern,expected_groups", - [ - # Simple regex with capture group - ( - "deploy prod", - re.compile(r"deploy (prod|staging)"), - {0: "deploy prod", 1: "prod"}, - ), - # Named groups - ( - "deploy-prod", - re.compile(r"deploy-(?Pprod|staging|dev)"), - {0: "deploy-prod", "env": "prod"}, - ), - # Question mark pattern - ( - "can you help?", - re.compile(r".*\?$"), - {0: "can you help?"}, - ), - # No match - ( - "deploy test", - re.compile(r"deploy (prod|staging)"), - None, - ), - ], - ) - def test_get_match_regex(self, text, pattern, expected_groups): - match = get_match(text, pattern) - - if expected_groups is None: - assert match is None - else: - assert match is not None - for group_key, expected_value in expected_groups.items(): - assert match.group(group_key) == expected_value - - def test_get_match_none(self): - match = get_match("any text", None) - - assert match is not None - assert match.group(0) == "any text" - - @pytest.mark.parametrize( - "text,pattern,should_match", - [ - # Invalid regex treated as literal - doesn't start with [invalid - ("test [invalid", "[invalid", False), - # Invalid regex treated as literal - starts with [invalid - ("[invalid regex", "[invalid", True), - ], - ) - def test_get_match_invalid_regex(self, text, pattern, should_match): - match = get_match(text, pattern) - - if should_match: - assert match is not None - else: - assert match is None - - @pytest.mark.parametrize( - "text,pattern,should_match", - [ - # Case-sensitive pattern - ("deploy", re.compile(r"DEPLOY", re.MULTILINE), False), - # Case-insensitive pattern - ("deploy", re.compile(r"DEPLOY", re.IGNORECASE), True), - # Multiline pattern - expects match from start of text - ("staging\nprod\ndev", re.compile(r"^prod$", re.MULTILINE), False), - ], - ) - def test_get_match_flag_preservation(self, text, pattern, should_match): - match = get_match(text, pattern) - - if should_match: - assert match is not None - else: - assert match is None - - class TestReDoSProtection: def test_redos_vulnerability(self, create_event): # Create a malicious comment that would cause potentially cause ReDoS @@ -794,25 +565,21 @@ def test_pattern_flags_preserved(self, text, pattern_str, flags, expected): class TestMention: @pytest.mark.parametrize( - "event_type,event_data,username,pattern,scope,expected_count,expected_mentions", + "event_type,event_data,username,expected_count,expected_mentions", [ # Basic mention extraction ( "issue_comment", {"comment": {"body": "@bot help"}}, "bot", - None, - None, 1, - [{"username": "bot", "text": "help"}], + [{"username": "bot"}], ), # No mentions in event ( "issue_comment", {"comment": {"body": "No mentions here"}}, None, - None, - None, 0, [], ), @@ -821,75 +588,45 @@ class TestMention: "issue_comment", {"comment": {"body": "@bot1 help @bot2 deploy @user test"}}, re.compile(r"bot\d"), - None, - None, 2, [ - {"username": "bot1", "text": "help"}, - {"username": "bot2", "text": "deploy"}, + {"username": "bot1"}, + {"username": "bot2"}, ], ), - # Scope filtering - matching scope + # Issue comment with issue data ( "issue_comment", {"comment": {"body": "@bot help"}, "issue": {}}, "bot", - None, - MentionScope.ISSUE, 1, - [{"username": "bot", "text": "help"}], + [{"username": "bot"}], ), - # Scope filtering - non-matching scope (PR comment on issue-only scope) + # PR comment (issue_comment with pull_request) ( "issue_comment", {"comment": {"body": "@bot help"}, "issue": {"pull_request": {}}}, "bot", - None, - MentionScope.ISSUE, - 0, - [], - ), - # Pattern matching on mention text - ( - "issue_comment", - {"comment": {"body": "@bot deploy prod @bot help me"}}, - "bot", - re.compile(r"deploy.*"), - None, 1, - [{"username": "bot", "text": "deploy prod"}], + [{"username": "bot"}], ), - # String pattern matching (case insensitive) - ( - "issue_comment", - {"comment": {"body": "@bot DEPLOY @bot help"}}, - "bot", - "deploy", - None, - 1, - [{"username": "bot", "text": "DEPLOY"}], - ), - # No username filter defaults to app name (bot) + # No username filter matches all mentions ( "issue_comment", {"comment": {"body": "@alice review @bot help"}}, None, - None, - None, - 1, - [{"username": "bot", "text": "help"}], + 2, + [{"username": "alice"}, {"username": "bot"}], ), # Get all mentions with wildcard regex pattern ( "issue_comment", {"comment": {"body": "@alice review @bob help"}}, re.compile(r".*"), - None, - None, 2, [ - {"username": "alice", "text": "review"}, - {"username": "bob", "text": "help"}, + {"username": "alice"}, + {"username": "bob"}, ], ), # PR review comment @@ -897,42 +634,22 @@ class TestMention: "pull_request_review_comment", {"comment": {"body": "@reviewer please check"}}, "reviewer", - None, - MentionScope.PR, 1, - [{"username": "reviewer", "text": "please check"}], + [{"username": "reviewer"}], ), # Commit comment ( "commit_comment", {"comment": {"body": "@bot test this commit"}}, "bot", - None, - MentionScope.COMMIT, - 1, - [{"username": "bot", "text": "test this commit"}], - ), - # Complex filtering: username + pattern + scope - ( - "issue_comment", - { - "comment": { - "body": "@mybot deploy staging @otherbot deploy prod @mybot help" - } - }, - "mybot", - re.compile(r"deploy\s+(staging|prod)"), - None, 1, - [{"username": "mybot", "text": "deploy staging"}], + [{"username": "bot"}], ), # Empty comment body ( "issue_comment", {"comment": {"body": ""}}, None, - None, - None, 0, [], ), @@ -941,10 +658,8 @@ class TestMention: "issue_comment", {"comment": {"body": "```\n@bot deploy\n```\n@bot help"}}, "bot", - None, - None, 1, - [{"username": "bot", "text": "help"}], + [{"username": "bot"}], ), ], ) @@ -954,247 +669,15 @@ def test_from_event( event_type, event_data, username, - pattern, - scope, expected_count, expected_mentions, ): event = create_event(event_type, **event_data) + scope = MentionScope.from_event(event) - mentions = list( - Mention.from_event(event, username=username, pattern=pattern, scope=scope) - ) + mentions = list(Mention.from_event(event, username=username, scope=scope)) assert len(mentions) == expected_count for mention, expected in zip(mentions, expected_mentions, strict=False): - assert isinstance(mention, Mention) assert mention.mention.username == expected["username"] - assert mention.mention.text == expected["text"] - assert mention.comment.body == event_data["comment"]["body"] - assert mention.scope == MentionScope.from_event(event) - - # Verify match object is set when pattern is provided - if pattern is not None: - assert mention.mention.match is not None - - @pytest.mark.parametrize( - "body,username,pattern,expected_matches", - [ - # Pattern groups are accessible via match object - ( - "@bot deploy prod to server1", - "bot", - re.compile(r"deploy\s+(\w+)\s+to\s+(\w+)"), - [("prod", "server1")], - ), - # Named groups - ( - "@bot deploy staging", - "bot", - re.compile(r"deploy\s+(?Pprod|staging|dev)"), - [{"env": "staging"}], - ), - ], - ) - def test_from_event_pattern_groups( - self, create_event, body, username, pattern, expected_matches - ): - event = create_event("issue_comment", comment={"body": body}) - - mentions = list(Mention.from_event(event, username=username, pattern=pattern)) - - assert len(mentions) == len(expected_matches) - for mention, expected in zip(mentions, expected_matches, strict=False): - assert mention.mention.match is not None - if isinstance(expected, tuple): - assert mention.mention.match.groups() == expected - elif isinstance(expected, dict): - assert mention.mention.match.groupdict() == expected - - -class TestExtractMentionText: - @pytest.fixture - def create_raw_mention(self): - def _create(username: str, position: int, end: int) -> RawMention: - # Create a dummy match object - extract_mention_text doesn't use it - dummy_text = f"@{username}" - match = re.match(r"@(\w+)", dummy_text) - assert match is not None # For type checker - return RawMention( - match=match, username=username, position=position, end=end - ) - - return _create - - @pytest.mark.parametrize( - "body,all_mentions_data,mention_end,expected_text", - [ - # Basic case: text after mention until next mention - ( - "@user1 hello world @user2 goodbye", - [("user1", 0, 6), ("user2", 19, 25)], - 6, - "hello world", - ), - # No text after mention (next mention immediately follows) - ( - "@user1@user2 hello", - [("user1", 0, 6), ("user2", 6, 12)], - 6, - "", - ), - # Empty text between mentions (whitespace only) - ( - "@user1 @user2", - [("user1", 0, 6), ("user2", 9, 15)], - 6, - "", - ), - # Single mention with text - ( - "@user hello world", - [("user", 0, 5)], - 5, - "hello world", - ), - # Mention at end of string - ( - "Hello @user", - [("user", 6, 11)], - 11, - "", - ), - # Multiple spaces and newlines (should be stripped) - ( - "@user1 \n\n hello world \n @user2", - [("user1", 0, 6), ("user2", 28, 34)], - 6, - "hello world", - ), - # Text with special characters - ( - "@bot deploy-prod --force @admin", - [("bot", 0, 4), ("admin", 25, 31)], - 4, - "deploy-prod --force", - ), - # Unicode text - ( - "@user こんにちは 🎉 @other", - [("user", 0, 5), ("other", 14, 20)], - 5, - "こんにちは 🎉", - ), - # Empty body - ( - "", - [], - 0, - "", - ), - # Complex multi-line text - ( - "@user1 Line 1\nLine 2\nLine 3 @user2 End", - [("user1", 0, 6), ("user2", 28, 34)], - 6, - "Line 1\nLine 2\nLine 3", - ), - # Trailing whitespace should be stripped - ( - "@user text with trailing spaces ", - [("user", 0, 5)], - 5, - "text with trailing spaces", - ), - ], - ) - def test_extract_mention_text( - self, create_raw_mention, body, all_mentions_data, mention_end, expected_text - ): - all_mentions = [ - create_raw_mention(username, pos, end) - for username, pos, end in all_mentions_data - ] - - result = extract_mention_text(body, 0, all_mentions, mention_end) - - assert result == expected_text - - @pytest.mark.parametrize( - "body,current_index,all_mentions_data,mention_end,expected_text", - [ - # Last mention: text until end of string - ( - "@user1 hello @user2 goodbye world", - 1, - [("user1", 0, 6), ("user2", 13, 19)], - 19, - "goodbye world", - ), - # Current index is not first mention - ( - "@alice intro @bob middle text @charlie end", - 1, # Looking at @bob - [ - ("alice", 0, 6), - ("bob", 13, 17), - ("charlie", 30, 38), - ], - 17, - "middle text", - ), - # Multiple mentions with different current indices - ( - "@a first @b second @c third @d fourth", - 2, # Looking at @c - [ - ("a", 0, 2), - ("b", 9, 11), - ("c", 19, 21), - ("d", 28, 30), - ], - 21, - "third", - ), - ], - ) - def test_extract_mention_text_with_different_current_index( - self, - create_raw_mention, - body, - current_index, - all_mentions_data, - mention_end, - expected_text, - ): - all_mentions = [ - create_raw_mention(username, pos, end) - for username, pos, end in all_mentions_data - ] - - result = extract_mention_text(body, current_index, all_mentions, mention_end) - - assert result == expected_text - - @pytest.mark.parametrize( - "current_index,expected_text", - [ - # Last mention - should get text until end - (1, "world"), - # Out of bounds current_index (should still work) - (10, "world"), - ], - ) - def test_extract_mention_text_with_invalid_indices( - self, create_raw_mention, current_index, expected_text - ): - all_mentions = [ - create_raw_mention("user1", 0, 6), - create_raw_mention("user2", 13, 19), - ] - - result = extract_mention_text( - "@user1 hello @user2 world", current_index, all_mentions, 19 - ) - - assert result == expected_text + assert mention.scope == scope diff --git a/tests/test_routing.py b/tests/test_routing.py index 0caa4ad..d3c7e4e 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -1,6 +1,5 @@ from __future__ import annotations -import asyncio import re import pytest @@ -13,12 +12,6 @@ from django_github_app.views import BaseWebhookView -@pytest.fixture(autouse=True) -def setup_test_app_name(override_app_settings): - with override_app_settings(NAME="bot"): - yield - - @pytest.fixture(autouse=True) def test_router(): import django_github_app.views @@ -122,713 +115,415 @@ def test_router_memory_stress_test_legacy(self): class TestMentionDecorator: - def test_basic_mention_no_pattern( - self, test_router, get_mock_github_api, create_event - ): - handler_called = False - handler_args = None + def test_mention(self, test_router, get_mock_github_api, create_event): + calls = [] @test_router.mention() def handle_mention(event, *args, **kwargs): - nonlocal handler_called, handler_args - handler_called = True - handler_args = (event, args, kwargs) + calls.append((event, args, kwargs)) event = create_event( "issue_comment", action="created", comment={"body": "@bot hello"}, ) - mock_gh = get_mock_github_api({}) - test_router.dispatch(event, mock_gh) - assert handler_called - assert handler_args[0] == event + test_router.dispatch(event, get_mock_github_api({})) - def test_mention_with_pattern(self, test_router, get_mock_github_api, create_event): - handler_called = False + assert len(calls) > 0 - @test_router.mention(pattern="help") - def help_handler(event, *args, **kwargs): - nonlocal handler_called - handler_called = True - return "help response" + @pytest.mark.asyncio + async def test_async_mention(self, test_router, aget_mock_github_api, create_event): + calls = [] + + @test_router.mention() + async def async_handle_mention(event, *args, **kwargs): + calls.append((event, args, kwargs)) event = create_event( "issue_comment", action="created", - comment={"body": "@bot help"}, - ) - mock_gh = get_mock_github_api({}) - test_router.dispatch(event, mock_gh) - - assert handler_called - - def test_mention_with_scope(self, test_router, get_mock_github_api, create_event): - pr_handler_called = False - - @test_router.mention(pattern="deploy", scope=MentionScope.PR) - def deploy_handler(event, *args, **kwargs): - nonlocal pr_handler_called - pr_handler_called = True - - mock_gh = get_mock_github_api({}) - - pr_event = create_event( - "pull_request_review_comment", - action="created", - comment={"body": "@bot deploy"}, + comment={"body": "@bot async hello"}, ) - test_router.dispatch(pr_event, mock_gh) - assert pr_handler_called + await test_router.adispatch(event, aget_mock_github_api({})) - issue_event = create_event( - "commit_comment", # This is NOT a PR event - action="created", - comment={"body": "@bot deploy"}, - ) - pr_handler_called = False # Reset - - test_router.dispatch(issue_event, mock_gh) + assert len(calls) > 0 - assert not pr_handler_called - - def test_case_insensitive_pattern( - self, test_router, get_mock_github_api, create_event + @pytest.mark.parametrize( + "username,body,expected_call_count", + [ + ("bot", "@bot help", 1), + ("bot", "@other-bot help", 0), + (re.compile(r".*-bot"), "@deploy-bot start @test-bot check @user help", 2), + (re.compile(r".*"), "@alice review @bob deploy @charlie test", 3), + ("", "@alice review @bob deploy @charlie test", 3), + ], + ) + def test_mention_with_username( + self, + test_router, + get_mock_github_api, + create_event, + username, + body, + expected_call_count, ): - handler_called = False + calls = [] - @test_router.mention(pattern="HELP") + @test_router.mention(username=username) def help_handler(event, *args, **kwargs): - nonlocal handler_called - handler_called = True + calls.append((event, args, kwargs)) event = create_event( "issue_comment", action="created", - comment={"body": "@bot help"}, + comment={"body": body}, ) - mock_gh = get_mock_github_api({}) - test_router.dispatch(event, mock_gh) - assert handler_called + test_router.dispatch(event, get_mock_github_api({})) - def test_multiple_decorators_on_same_function( - self, test_router, get_mock_github_api, create_event - ): - call_counts = {"help": 0, "h": 0, "?": 0} + assert len(calls) == expected_call_count - @test_router.mention(pattern="help") - @test_router.mention(pattern="h") - @test_router.mention(pattern="?") - def help_handler(event, *args, **kwargs): - mention = kwargs.get("context") - if mention and mention.mention: - text = mention.mention.text.strip() - if text in call_counts: - call_counts[text] += 1 - - for pattern in ["help", "h", "?"]: - event = create_event( - "issue_comment", - action="created", - comment={"body": f"@bot {pattern}"}, - ) - mock_gh = get_mock_github_api({}) - test_router.dispatch(event, mock_gh) - - # Check expected behavior: - # - "help" matches both "help" pattern and "h" pattern (since "help" starts with "h") - # - "h" matches only "h" pattern - # - "?" matches only "?" pattern - assert call_counts["help"] == 2 # Matched by both "help" and "h" patterns - assert call_counts["h"] == 1 # Matched only by "h" pattern - assert call_counts["?"] == 1 # Matched only by "?" pattern - - def test_async_mention_handler( - self, test_router, aget_mock_github_api, create_event + @pytest.mark.parametrize( + "username,body,expected_call_count", + [ + ("bot", "@bot help", 1), + ("bot", "@other-bot help", 0), + (re.compile(r".*-bot"), "@deploy-bot start @test-bot check @user help", 2), + (re.compile(r".*"), "@alice review @bob deploy @charlie test", 3), + ("", "@alice review @bob deploy @charlie test", 3), + ], + ) + @pytest.mark.asyncio + async def test_async_mention_with_username( + self, + test_router, + aget_mock_github_api, + create_event, + username, + body, + expected_call_count, ): - handler_called = False + calls = [] - @test_router.mention(pattern="async-test") - async def async_handler(event, *args, **kwargs): - nonlocal handler_called - handler_called = True - return "async response" + @test_router.mention(username=username) + async def help_handler(event, *args, **kwargs): + calls.append((event, args, kwargs)) event = create_event( "issue_comment", action="created", - comment={"body": "@bot async-test"}, + comment={"body": body}, ) - mock_gh = aget_mock_github_api({}) - asyncio.run(test_router.adispatch(event, mock_gh)) - - assert handler_called - - def test_sync_mention_handler(self, test_router, get_mock_github_api, create_event): - handler_called = False - - @test_router.mention(pattern="sync-test") - def sync_handler(event, *args, **kwargs): - nonlocal handler_called - handler_called = True - return "sync response" - - event = create_event( - "issue_comment", - action="created", - comment={"body": "@bot sync-test"}, - ) - mock_gh = get_mock_github_api({}) - test_router.dispatch(event, mock_gh) + await test_router.adispatch(event, aget_mock_github_api({})) - assert handler_called + assert len(calls) == expected_call_count - def test_scope_validation_issue_comment_on_issue( - self, test_router, get_mock_github_api, create_event + @pytest.mark.parametrize( + "scope", [MentionScope.PR, MentionScope.ISSUE, MentionScope.COMMIT] + ) + def test_mention_with_scope( + self, + test_router, + get_mock_github_api, + create_event, + scope, ): - handler_called = False + calls = [] - @test_router.mention(pattern="issue-only", scope=MentionScope.ISSUE) - def issue_handler(event, *args, **kwargs): - nonlocal handler_called - handler_called = True + @test_router.mention(scope=scope) + def scoped_handler(event, *args, **kwargs): + calls.append((event, args, kwargs)) - event = create_event( - "issue_comment", - action="created", - comment={"body": "@bot issue-only"}, - ) mock_gh = get_mock_github_api({}) - test_router.dispatch(event, mock_gh) - - assert handler_called - def test_scope_validation_issue_comment_on_pr( - self, test_router, get_mock_github_api, create_event - ): - handler_called = False + expected_events = scope.get_events() - @test_router.mention(pattern="issue-only", scope=MentionScope.ISSUE) - def issue_handler(event, *args, **kwargs): - nonlocal handler_called - handler_called = True + # Test all events that should match this scope + for event_action in expected_events: + # Special case: PR scope issue_comment needs pull_request field + event_kwargs = {} + if scope == MentionScope.PR and event_action.event == "issue_comment": + event_kwargs["issue"] = {"pull_request": {"url": "..."}} - # Issue comment on a pull request (has pull_request field) - event = create_event( - "issue_comment", - action="created", - issue={ - "pull_request": {"url": "https://api.github.com/..."}, - }, - comment={"body": "@bot issue-only"}, - ) - mock_gh = get_mock_github_api({}) - test_router.dispatch(event, mock_gh) + event = create_event( + event_action.event, action=event_action.action, **event_kwargs + ) - assert not handler_called + test_router.dispatch(event, mock_gh) - def test_scope_validation_pr_scope_on_pr( - self, test_router, get_mock_github_api, create_event + assert len(calls) == len(expected_events) + + # Test that events from other scopes don't trigger this handler + for other_scope in MentionScope: + if other_scope == scope: + continue + + for event_action in other_scope.get_events(): + # Ensure the event has the right structure for its intended scope + event_kwargs = {} + if ( + other_scope == MentionScope.PR + and event_action.event == "issue_comment" + ): + event_kwargs["issue"] = {"pull_request": {"url": "..."}} + elif ( + other_scope == MentionScope.ISSUE + and event_action.event == "issue_comment" + ): + # Explicitly set empty issue (no pull_request) + event_kwargs["issue"] = {} + + event = create_event( + event_action.event, action=event_action.action, **event_kwargs + ) + test_router.dispatch(event, mock_gh) + + assert len(calls) == len(expected_events) + + @pytest.mark.parametrize( + "scope", [MentionScope.PR, MentionScope.ISSUE, MentionScope.COMMIT] + ) + @pytest.mark.asyncio + async def test_async_mention_with_scope( + self, + test_router, + aget_mock_github_api, + create_event, + scope, ): - handler_called = False + calls = [] - @test_router.mention(pattern="pr-only", scope=MentionScope.PR) - def pr_handler(event, *args, **kwargs): - nonlocal handler_called - handler_called = True + @test_router.mention(scope=scope) + async def async_scoped_handler(event, *args, **kwargs): + calls.append((event, args, kwargs)) - event = create_event( - "issue_comment", - action="created", - issue={ - "pull_request": {"url": "https://api.github.com/..."}, - }, - comment={"body": "@bot pr-only"}, - ) - mock_gh = get_mock_github_api({}) - test_router.dispatch(event, mock_gh) + mock_gh = aget_mock_github_api({}) - assert handler_called + expected_events = scope.get_events() - def test_scope_validation_pr_scope_on_issue( - self, test_router, get_mock_github_api, create_event - ): - handler_called = False + # Test all events that should match this scope + for event_action in expected_events: + # Special case: PR scope issue_comment needs pull_request field + event_kwargs = {} + if scope == MentionScope.PR and event_action.event == "issue_comment": + event_kwargs["issue"] = {"pull_request": {"url": "..."}} - @test_router.mention(pattern="pr-only", scope=MentionScope.PR) - def pr_handler(event, *args, **kwargs): - nonlocal handler_called - handler_called = True + event = create_event( + event_action.event, action=event_action.action, **event_kwargs + ) - event = create_event( - "issue_comment", - action="created", - comment={"body": "@bot pr-only"}, - ) - mock_gh = get_mock_github_api({}) - test_router.dispatch(event, mock_gh) + await test_router.adispatch(event, mock_gh) - assert not handler_called + assert len(calls) == len(expected_events) - def test_scope_validation_commit_scope( - self, test_router, get_mock_github_api, create_event - ): - """Test that COMMIT scope works for commit comments.""" - handler_called = False + # Test that events from other scopes don't trigger this handler + for other_scope in MentionScope: + if other_scope == scope: + continue - @test_router.mention(pattern="commit-only", scope=MentionScope.COMMIT) - def commit_handler(event, *args, **kwargs): - nonlocal handler_called - handler_called = True + for event_action in other_scope.get_events(): + # Ensure the event has the right structure for its intended scope + event_kwargs = {} + if ( + other_scope == MentionScope.PR + and event_action.event == "issue_comment" + ): + event_kwargs["issue"] = {"pull_request": {"url": "..."}} + elif ( + other_scope == MentionScope.ISSUE + and event_action.event == "issue_comment" + ): + # Explicitly set empty issue (no pull_request) + event_kwargs["issue"] = {} - event = create_event( - "commit_comment", - action="created", - comment={"body": "@bot commit-only"}, - ) - mock_gh = get_mock_github_api({}) - test_router.dispatch(event, mock_gh) + event = create_event( + event_action.event, action=event_action.action, **event_kwargs + ) + + await test_router.adispatch(event, mock_gh) - assert handler_called + assert len(calls) == len(expected_events) - def test_scope_validation_no_scope( + def test_issue_scope_excludes_pr_comments( self, test_router, get_mock_github_api, create_event ): - call_count = 0 + calls = [] - @test_router.mention(pattern="all-contexts") - def all_handler(event, *args, **kwargs): - nonlocal call_count - call_count += 1 + @test_router.mention(scope=MentionScope.ISSUE) + def issue_only_handler(event, *args, **kwargs): + calls.append((event, args, kwargs)) mock_gh = get_mock_github_api({}) - event = create_event( + # Test that regular issue comments trigger the handler + issue_event = create_event( "issue_comment", action="created", - comment={"body": "@bot all-contexts"}, + comment={"body": "@bot help"}, + issue={}, # No pull_request field ) - test_router.dispatch(event, mock_gh) - event = create_event( + test_router.dispatch(issue_event, mock_gh) + + assert len(calls) == 1 + + # Test that PR comments don't trigger the handler + pr_event = create_event( "issue_comment", action="created", - issue={ - "pull_request": {"url": "..."}, - }, - comment={"body": "@bot all-contexts"}, + comment={"body": "@bot help"}, + issue={"pull_request": {"url": "https://github.com/test/repo/pull/1"}}, ) - test_router.dispatch(event, mock_gh) - event = create_event( - "commit_comment", - action="created", - comment={"body": "@bot all-contexts"}, - ) - test_router.dispatch(event, mock_gh) + test_router.dispatch(pr_event, mock_gh) - assert call_count == 3 + # Should still be 1 - no new calls + assert len(calls) == 1 - def test_mention_enrichment_pr_scope( - self, test_router, get_mock_github_api, create_event + @pytest.mark.parametrize( + "event_kwargs,expected_call_count", + [ + # All conditions met + ( + { + "comment": {"body": "@deploy-bot deploy now"}, + "issue": {"pull_request": {"url": "..."}}, + }, + 1, + ), + # Wrong username + ( + { + "comment": {"body": "@bot deploy now"}, + "issue": {"pull_request": {"url": "..."}}, + }, + 0, + ), + # Different mention text (shouldn't matter without pattern) + ( + { + "comment": {"body": "@deploy-bot help"}, + "issue": {"pull_request": {"url": "..."}}, + }, + 1, + ), + # Wrong scope (issue instead of PR) + ( + { + "comment": {"body": "@deploy-bot deploy now"}, + "issue": {}, # No pull_request field + }, + 0, + ), + ], + ) + def test_combined_mention_filters( + self, + test_router, + get_mock_github_api, + create_event, + event_kwargs, + expected_call_count, ): - handler_called = False - captured_kwargs = {} - - @test_router.mention(pattern="deploy") - def deploy_handler(event, *args, **kwargs): - nonlocal handler_called, captured_kwargs - handler_called = True - captured_kwargs = kwargs.copy() + calls = [] - event = create_event( - "issue_comment", - action="created", - comment={"body": "@bot deploy"}, - issue={ - "pull_request": { - "url": "https://api.github.com/repos/test/repo/pulls/42" - }, - }, + @test_router.mention( + username=re.compile(r".*-bot"), + scope=MentionScope.PR, ) + def combined_filter_handler(event, *args, **kwargs): + calls.append((event, args, kwargs)) - mock_gh = get_mock_github_api({}) - test_router.dispatch(event, mock_gh) - - assert handler_called - assert "context" in captured_kwargs + event = create_event("issue_comment", action="created", **event_kwargs) - mention = captured_kwargs["context"] + test_router.dispatch(event, get_mock_github_api({})) - assert mention.comment.body == "@bot deploy" - assert mention.mention.text == "deploy" - assert mention.scope.name == "PR" + assert len(calls) == expected_call_count + def test_mention_context(self, test_router, get_mock_github_api, create_event): + calls = [] -class TestUpdatedMentionContext: - def test_mention_context_structure( - self, test_router, get_mock_github_api, create_event - ): - handler_called = False - captured_mention = None - - @test_router.mention(pattern="test") + @test_router.mention() def test_handler(event, *args, **kwargs): - nonlocal handler_called, captured_mention - handler_called = True - captured_mention = kwargs.get("context") + calls.append((event, args, kwargs)) event = create_event( "issue_comment", action="created", - comment={ - "body": "@bot test", - "created_at": "2024-01-01T12:00:00Z", - "html_url": "https://github.com/test/repo/issues/1#issuecomment-123", - }, + comment={"body": "@bot test"}, ) - mock_gh = get_mock_github_api({}) - test_router.dispatch(event, mock_gh) + test_router.dispatch(event, get_mock_github_api({})) - assert handler_called + captured_mention = calls[0][2]["context"] - comment = captured_mention.comment - - assert comment.body == "@bot test" - assert comment.author is not None # Generated by faker - assert comment.url == "https://github.com/test/repo/issues/1#issuecomment-123" - assert len(comment.mentions) == 1 + assert captured_mention.scope.name == "ISSUE" triggered = captured_mention.mention assert triggered.username == "bot" - assert triggered.text == "test" assert triggered.position == 0 assert triggered.line_info.lineno == 1 - assert captured_mention.scope.name == "ISSUE" - - def test_multiple_mentions_mention( - self, test_router, get_mock_github_api, create_event - ): - handler_called = False - captured_mention = None - - @test_router.mention(pattern="deploy") - def deploy_handler(event, *args, **kwargs): - nonlocal handler_called, captured_mention - handler_called = True - captured_mention = kwargs.get("context") - - event = create_event( - "issue_comment", - action="created", - comment={ - "body": "@bot help\n@bot deploy production", - "created_at": "2024-01-01T12:00:00Z", - "html_url": "https://github.com/test/repo/issues/2#issuecomment-456", - }, - ) - - mock_gh = get_mock_github_api({}) - test_router.dispatch(event, mock_gh) - - assert handler_called - assert captured_mention is not None - assert len(captured_mention.comment.mentions) == 2 - assert captured_mention.mention.text == "deploy production" - assert captured_mention.mention.line_info.lineno == 2 - - first_mention = captured_mention.comment.mentions[0] - second_mention = captured_mention.comment.mentions[1] - - assert first_mention.next_mention is second_mention - assert second_mention.previous_mention is first_mention - - def test_mention_without_pattern( - self, test_router, get_mock_github_api, create_event - ): - handler_called = False - captured_mention = None - - @test_router.mention() # No pattern specified - def general_handler(event, *args, **kwargs): - nonlocal handler_called, captured_mention - handler_called = True - captured_mention = kwargs.get("context") - - event = create_event( - "issue_comment", - action="created", - comment={ - "body": "@bot can you help me?", - "created_at": "2024-01-01T12:00:00Z", - "html_url": "https://github.com/test/repo/issues/3#issuecomment-789", - }, - ) - - mock_gh = get_mock_github_api({}) - test_router.dispatch(event, mock_gh) - - assert handler_called - assert captured_mention.mention.text == "can you help me?" - assert captured_mention.mention.username == "bot" - @pytest.mark.asyncio - async def test_async_mention_context_structure( + async def test_async_mention_context( self, test_router, aget_mock_github_api, create_event ): - handler_called = False - captured_mention = None + calls = [] - @test_router.mention(pattern="async-test") + @test_router.mention() async def async_handler(event, *args, **kwargs): - nonlocal handler_called, captured_mention - handler_called = True - captured_mention = kwargs.get("context") - - event = create_event( - "issue_comment", - action="created", - comment={ - "body": "@bot async-test now", - "created_at": "2024-01-01T13:00:00Z", - "html_url": "https://github.com/test/repo/issues/4#issuecomment-999", - }, - ) - - mock_gh = aget_mock_github_api({}) - await test_router.adispatch(event, mock_gh) - - assert handler_called - assert captured_mention.comment.body == "@bot async-test now" - assert captured_mention.mention.text == "async-test now" - - -class TestFlexibleMentionTriggers: - def test_pattern_parameter_string( - self, test_router, get_mock_github_api, create_event - ): - handler_called = False - captured_mention = None - - @test_router.mention(pattern="deploy") - def deploy_handler(event, *args, **kwargs): - nonlocal handler_called, captured_mention - handler_called = True - captured_mention = kwargs.get("context") + calls.append((event, args, kwargs)) event = create_event( "issue_comment", action="created", - comment={"body": "@bot deploy production"}, + comment={"body": "@bot async-test now"}, ) - mock_gh = get_mock_github_api({}) - test_router.dispatch(event, mock_gh) - - assert handler_called - assert captured_mention.mention.match is not None - assert captured_mention.mention.match.group(0) == "deploy" - # Should not match - pattern in middle - handler_called = False - event.data["comment"]["body"] = "@bot please deploy" - test_router.dispatch(event, mock_gh) + await test_router.adispatch(event, aget_mock_github_api({})) - assert not handler_called - - def test_pattern_parameter_regex( - self, test_router, get_mock_github_api, create_event - ): - handler_called = False - captured_mention = None - - @test_router.mention(pattern=re.compile(r"deploy-(?Pprod|staging|dev)")) - def deploy_env_handler(event, *args, **kwargs): - nonlocal handler_called, captured_mention - handler_called = True - captured_mention = kwargs.get("context") - - event = create_event( - "issue_comment", - action="created", - comment={"body": "@bot deploy-staging"}, - ) - mock_gh = get_mock_github_api({}) - test_router.dispatch(event, mock_gh) + captured_mention = calls[0][2]["context"] - assert handler_called - assert captured_mention.mention.match is not None - assert captured_mention.mention.match.group("env") == "staging" - - def test_username_parameter_exact( - self, test_router, get_mock_github_api, create_event - ): - handler_called = False - - @test_router.mention(username="deploy-bot") - def deploy_bot_handler(event, *args, **kwargs): - nonlocal handler_called - handler_called = True - - # Should match deploy-bot - event = create_event( - "issue_comment", - action="created", - comment={"body": "@deploy-bot run tests"}, - ) - mock_gh = get_mock_github_api({}) - test_router.dispatch(event, mock_gh) - - assert handler_called - - # Should not match bot - handler_called = False - event.data["comment"]["body"] = "@bot run tests" - test_router.dispatch(event, mock_gh) - - assert not handler_called - - def test_username_parameter_regex( - self, test_router, get_mock_github_api, create_event - ): - handler_count = 0 - - @test_router.mention(username=re.compile(r".*-bot")) - def any_bot_handler(event, *args, **kwargs): - nonlocal handler_count - handler_count += 1 + assert captured_mention.scope.name == "ISSUE" - event = create_event( - "issue_comment", - action="created", - comment={"body": "@deploy-bot start @test-bot check @user help"}, - ) - mock_gh = get_mock_github_api({}) - test_router.dispatch(event, mock_gh) + triggered = captured_mention.mention - # Should be called twice (deploy-bot and test-bot) - assert handler_count == 2 + assert triggered.username == "bot" + assert triggered.position == 0 + assert triggered.line_info.lineno == 1 - def test_username_all_mentions( + def test_mention_context_multiple_mentions( self, test_router, get_mock_github_api, create_event ): - mentions_seen = [] - - @test_router.mention(username=re.compile(r".*")) - def all_mentions_handler(event, *args, **kwargs): - mention = kwargs.get("context") - mentions_seen.append(mention.mention.username) - - event = create_event( - "issue_comment", - action="created", - comment={"body": "@alice review @bob deploy @charlie test"}, - ) - mock_gh = get_mock_github_api({}) - test_router.dispatch(event, mock_gh) - - assert mentions_seen == ["alice", "bob", "charlie"] - - def test_combined_filters(self, test_router, get_mock_github_api, create_event): calls = [] - @test_router.mention( - username=re.compile(r".*-bot"), - pattern="deploy", - scope=MentionScope.PR, - ) - def restricted_deploy(event, *args, **kwargs): - calls.append(kwargs) - - def make_event(body): - return create_event( - "issue_comment", - action="created", - comment={"body": body}, - issue={"pull_request": {"url": "..."}}, - ) - - # All conditions met - event1 = make_event("@deploy-bot deploy now") - mock_gh = get_mock_github_api({}) - test_router.dispatch(event1, mock_gh) - - assert len(calls) == 1 - - # Wrong username pattern - calls.clear() - event2 = make_event("@bot deploy now") - test_router.dispatch(event2, mock_gh) - - assert len(calls) == 0 - - # Wrong pattern - calls.clear() - event3 = make_event("@deploy-bot help") - test_router.dispatch(event3, mock_gh) - - assert len(calls) == 0 - - # Wrong scope (issue instead of PR) - calls.clear() - event4 = create_event( - "issue_comment", - action="created", - comment={"body": "@deploy-bot deploy now"}, - issue={}, # No pull_request field - ) - test_router.dispatch(event4, mock_gh) - - assert len(calls) == 0 - - def test_multiple_decorators_different_patterns( - self, test_router, get_mock_github_api, create_event - ): - patterns_matched = [] - - @test_router.mention(pattern=re.compile(r"deploy")) - @test_router.mention(pattern=re.compile(r"ship")) - @test_router.mention(pattern=re.compile(r"release")) + @test_router.mention() def deploy_handler(event, *args, **kwargs): - mention = kwargs.get("context") - patterns_matched.append(mention.mention.text.split()[0]) + calls.append((event, args, kwargs)) event = create_event( "issue_comment", action="created", - comment={"body": "@bot ship it"}, + comment={"body": "@bot help\n@second-bot deploy production"}, ) - mock_gh = get_mock_github_api({}) - test_router.dispatch(event, mock_gh) - assert patterns_matched == ["ship"] + test_router.dispatch(event, get_mock_github_api({})) - def test_question_pattern(self, test_router, get_mock_github_api, create_event): - questions_received = [] + assert len(calls) == 2 - @test_router.mention(pattern=re.compile(r".*\?$")) - def question_handler(event, *args, **kwargs): - mention = kwargs.get("context") - questions_received.append(mention.mention.text) - - event = create_event( - "issue_comment", - action="created", - comment={"body": "@bot what is the status?"}, - ) - mock_gh = get_mock_github_api({}) - test_router.dispatch(event, mock_gh) + first = calls[0][2]["context"].mention + second = calls[1][2]["context"].mention - assert questions_received == ["what is the status?"] + assert first.username == "bot" + assert first.line_info.lineno == 1 + assert first.previous_mention is None + assert first.next_mention is second - # Non-question should not match - questions_received.clear() - event.data["comment"]["body"] = "@bot please help" - test_router.dispatch(event, mock_gh) - assert questions_received == [] + assert second.username == "second-bot" + assert second.line_info.lineno == 2 + assert second.previous_mention is first + assert second.next_mention is None