From 9b1414355a284db2b61bc1626bcd8a6d92f87bee Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 17 Jun 2025 13:49:04 -0500 Subject: [PATCH 01/35] 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 d09b071f536d6b83cfb962c11405c6f2ddfa04d1 Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 17 Jun 2025 14:35:58 -0500 Subject: [PATCH 02/35] 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 e4406a440a8edb8c447f9521d21d0b26f306c2aa Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 17 Jun 2025 14:56:48 -0500 Subject: [PATCH 03/35] 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 fdb03f3c890f00654912e430b44ebc6a75f05ee1 Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 17 Jun 2025 15:09:53 -0500 Subject: [PATCH 04/35] 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 82e07d0169b5b19bf07248afb125ad19c7363583 Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 17 Jun 2025 15:20:56 -0500 Subject: [PATCH 05/35] 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 25b2338e7c65ee67f31bca49eca8d85cc52e85b0 Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 17 Jun 2025 16:02:50 -0500 Subject: [PATCH 06/35] 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 01754031e3022d7168dfc64a24c945a506465b9b Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 17 Jun 2025 20:41:05 -0500 Subject: [PATCH 07/35] 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 0577ecd817f9b3710b91c79a4c34a825ddbef5cb Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 17 Jun 2025 22:49:36 -0500 Subject: [PATCH 08/35] 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 27dba6cc58221a3abe5a6a474b0408eb8c37abbb Mon Sep 17 00:00:00 2001 From: Josh Date: Wed, 18 Jun 2025 11:01:42 -0500 Subject: [PATCH 09/35] 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 f139a7eca0cab1e74aa7fe10c34c13613d5d5993 Mon Sep 17 00:00:00 2001 From: Josh Date: Wed, 18 Jun 2025 12:43:19 -0500 Subject: [PATCH 10/35] 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 b2b6a2d69935e12034cd7212e40fe49ca9d6938c Mon Sep 17 00:00:00 2001 From: Josh Date: Wed, 18 Jun 2025 18:46:11 -0500 Subject: [PATCH 11/35] 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 f07616511d16b40494b7886003ce1170a284e090 Mon Sep 17 00:00:00 2001 From: Josh Date: Wed, 18 Jun 2025 20:31:34 -0500 Subject: [PATCH 12/35] 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 86801b85c712a7a901373a0e89e53e7a6fb1f5d0 Mon Sep 17 00:00:00 2001 From: Josh Date: Wed, 18 Jun 2025 20:36:46 -0500 Subject: [PATCH 13/35] 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 7eef0c072b400abf06a6d02748be6fafe083e189 Mon Sep 17 00:00:00 2001 From: Josh Date: Wed, 18 Jun 2025 22:25:38 -0500 Subject: [PATCH 14/35] 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 e33f5e240a089545bb7e7d6785177ab334f293e6 Mon Sep 17 00:00:00 2001 From: Josh Date: Wed, 18 Jun 2025 22:37:59 -0500 Subject: [PATCH 15/35] 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 f04dce4e867ab81ba01fb15a2d84f6ace7696b0c Mon Sep 17 00:00:00 2001 From: Josh Date: Wed, 18 Jun 2025 22:58:59 -0500 Subject: [PATCH 16/35] 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 e137cc0834f9061b4a41b0ee7583a935c81916b0 Mon Sep 17 00:00:00 2001 From: Josh Date: Wed, 18 Jun 2025 23:00:37 -0500 Subject: [PATCH 17/35] 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 48bf0851b2c7028beb1910cde658faf4c2d1dd92 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/35] [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 d867379e839aef6c80114e7f43f339ba88e6775c Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 19 Jun 2025 11:51:36 -0500 Subject: [PATCH 19/35] 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 d4c5d05685c11f32e9c5e30735c1376a5b4c2d75 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/35] [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 1785cfc0ca4b6fd78e9e51977fc173a47da3aa1c Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 19 Jun 2025 12:02:06 -0500 Subject: [PATCH 21/35] 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 1511264d0b0f7e9ac370952af37235686aada499 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 19 Jun 2025 18:08:24 -0500 Subject: [PATCH 22/35] 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 f2e1b24ead308d38c0494c4e03eeea8a03a8d694 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 19 Jun 2025 18:20:52 -0500 Subject: [PATCH 23/35] 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 623e7e80aa3c6f39604b87828c061271d578cdc3 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 19 Jun 2025 18:28:23 -0500 Subject: [PATCH 24/35] 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 ecd9f5256a72fb3b5e6cb0aaa25476c359314f9f Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 19 Jun 2025 18:30:28 -0500 Subject: [PATCH 25/35] 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 3fe197ddee7cb27f192e858a8bdc20e814f8b0c2 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 20 Jun 2025 14:35:47 -0500 Subject: [PATCH 26/35] 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 3212976805bf6b783320cbe2af8b6078ee380311 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/35] [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 f85a6f5ec0c1e177b9083107374f13547d54edaa Mon Sep 17 00:00:00 2001 From: Josh Date: Mon, 23 Jun 2025 16:13:25 -0500 Subject: [PATCH 28/35] 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 From d396bd733358de07e81afd922c10c710f1230f9a Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 25 Jul 2025 11:08:01 -0500 Subject: [PATCH 29/35] update sync version of mock_github_api client --- tests/conftest.py | 31 +++++++++++++++++-------------- tests/test_models.py | 4 ++-- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 8e6e0cf..081eab2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -154,22 +154,25 @@ async def mock_getiter(*args, **kwargs): @pytest.fixture 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) + # For sync tests, we still need an async mock because get_gh_client + # always returns AsyncGitHubAPI, even when used through async_to_sync. + # long term, we'll probably need to just duplicate the code between + # sync and async versions instead of relying on async_to_sync/sync_to_async + # from asgiref, so we'll keep this separate sync mock github api client + # around so we can swap the internals out without changing tests (hopefully) + mock_api = AsyncMock(spec=AsyncGitHubAPI) - def mock_getitem(*args, **kwargs): + async def mock_getitem(*args, **kwargs): return return_data - def mock_getiter(*args, **kwargs): - yield from return_data - - def mock_post(*args, **kwargs): - pass + async def mock_getiter(*args, **kwargs): + for data in return_data: + yield data mock_api.getitem = mock_getitem mock_api.getiter = mock_getiter - mock_api.post = mock_post + mock_api.__aenter__.return_value = mock_api + mock_api.__aexit__.return_value = None mock_api.installation_id = installation_id return mock_api @@ -178,11 +181,11 @@ def mock_post(*args, **kwargs): @pytest.fixture -def installation(aget_mock_github_api, baker): +def installation(get_mock_github_api, baker): installation = baker.make( "django_github_app.Installation", installation_id=seq.next() ) - mock_github_api = aget_mock_github_api( + mock_github_api = get_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 +213,14 @@ async def ainstallation(aget_mock_github_api, baker): @pytest.fixture -def repository(installation, aget_mock_github_api, baker): +def repository(installation, get_mock_github_api, baker): repository = baker.make( "django_github_app.Repository", repository_id=seq.next(), full_name="owner/repo", installation=installation, ) - mock_github_api = aget_mock_github_api( + mock_github_api = get_mock_github_api( [ { "number": 1, diff --git a/tests/test_models.py b/tests/test_models.py index a562931..16e7d76 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -289,10 +289,10 @@ def test_refresh_from_gh( account_type, private_key, installation, - aget_mock_github_api, + get_mock_github_api, override_app_settings, ): - mock_github_api = aget_mock_github_api({"foo": "bar"}) + mock_github_api = get_mock_github_api({"foo": "bar"}) installation.get_gh_client = MagicMock(return_value=mock_github_api) with override_app_settings(PRIVATE_KEY=private_key): From c250a4a108d6c81166ec90700a7dea7bf863f894 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 25 Jul 2025 13:17:59 -0500 Subject: [PATCH 30/35] update README documentation for new feature --- README.md | 188 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) diff --git a/README.md b/README.md index 3e4fd00..f3b45cc 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,8 @@ cog.outl(f"- Django {', '.join([version for version in DJ_VERSIONS if version != ## Getting Started +### Webhook Events + django-github-app provides a router-based system for handling GitHub webhook events, built on top of [gidgethub](https://github.com/gidgethub/gidgethub). The router matches incoming webhooks to your handler functions based on the event type and optional action. To start handling GitHub webhooks, create your event handlers in a new file (e.g., `events.py`) within your Django app. @@ -315,6 +317,77 @@ For more information about GitHub webhook events and payloads, see these pages i For more details about how `gidgethub.sansio.Event` and webhook routing work, see the [gidgethub documentation](https://gidgethub.readthedocs.io). +### Mentions + +django-github-app provides a `@gh.mention` decorator to easily respond when your GitHub App is mentioned in comments. This is useful for building interactive bots that respond to user commands. + +For ASGI projects: + +```python +# your_app/events.py +import re +from django_github_app.routing import GitHubRouter +from django_github_app.mentions import MentionScope + +gh = GitHubRouter() + +# Respond to mentions of your bot +@gh.mention(username="mybot") +async def handle_bot_mention(event, gh, *args, context, **kwargs): + """Respond when someone mentions @mybot""" + mention = context.mention + issue_url = event.data["issue"]["comments_url"] + + await gh.post( + issue_url, + data={"body": f"Hello! You mentioned me at position {mention.position}"} + ) + +# Use regex to match multiple bot names +@gh.mention(username=re.compile(r".*-bot")) +async def handle_any_bot(event, gh, *args, context, **kwargs): + """Respond to any mention ending with '-bot'""" + mention = context.mention + await gh.post( + event.data["issue"]["comments_url"], + data={"body": f"Bot {mention.username} at your service!"} + ) + +# Restrict to pull request mentions only +@gh.mention(username="deploy-bot", scope=MentionScope.PR) +async def handle_deploy_command(event, gh, *args, context, **kwargs): + """Only respond to @deploy-bot in pull requests""" + await gh.post( + event.data["issue"]["comments_url"], + data={"body": "Starting deployment..."} + ) +``` + +For WSGI projects: + +```python +# your_app/events.py +import re +from django_github_app.routing import GitHubRouter +from django_github_app.mentions import MentionScope + +gh = GitHubRouter() + +# Respond to mentions of your bot +@gh.mention(username="mybot") +def handle_bot_mention(event, gh, *args, context, **kwargs): + """Respond when someone mentions @mybot""" + mention = context.mention + issue_url = event.data["issue"]["comments_url"] + + gh.post( + issue_url, + data={"body": f"Hello! You mentioned me at position {mention.position}"} + ) +``` + +The mention decorator automatically extracts mentions from comments and provides context about each mention. Handlers are called once for each matching mention in a comment. + ## Features ### GitHub API Client @@ -485,6 +558,121 @@ The library includes event handlers for managing GitHub App installations and re The library loads either async or sync versions of these handlers based on your `GITHUB_APP["WEBHOOK_TYPE"]` setting. +### Mentions + +The `@gh.mention` decorator provides a powerful way to build interactive GitHub Apps that respond to mentions in comments. When users mention your app (e.g., `@mybot help`), the decorator automatically detects these mentions and routes them to your handlers. + +#### How It Works + +The mention system: +1. Monitors incoming webhook events for comments containing mentions +2. Extracts all mentions while ignoring those in code blocks, inline code, or blockquotes +3. Filters mentions based on your specified criteria (username pattern, scope) +4. Calls your handler once for each matching mention, providing rich context + +#### Mention Context + +Each handler receives a `context` parameter with detailed information about the mention: + +```python +@gh.mention(username="mybot") +async def handle_mention(event, gh, *args, context, **kwargs): + mention = context.mention + + # Access mention details + print(f"Username: {mention.username}") # "mybot" + print(f"Position: {mention.position}") # Character position in comment + print(f"Line: {mention.line_info.lineno}") # Line number (1-based) + print(f"Line text: {mention.line_info.text}") # Full text of the line + + # Navigate between mentions in the same comment + if mention.previous_mention: + print(f"Previous: @{mention.previous_mention.username}") + if mention.next_mention: + print(f"Next: @{mention.next_mention.username}") + + # Check the scope (ISSUE, PR, or COMMIT) + print(f"Scope: {context.scope}") +``` + +#### Filtering Options + +##### Username Patterns + +Filter mentions by username using exact matches or regular expressions: + +```python +# Exact match (case-insensitive) +@gh.mention(username="deploy-bot") + +# Regular expression pattern +@gh.mention(username=re.compile(r".*-bot")) + +# Respond to all mentions (no filter) +@gh.mention() +``` + +##### Scopes + +Limit mentions to specific GitHub contexts: + +```python +from django_github_app.mentions import MentionScope + +# Only respond in issues (not PRs) +@gh.mention(username="issue-bot", scope=MentionScope.ISSUE) + +# Only respond in pull requests +@gh.mention(username="review-bot", scope=MentionScope.PR) + +# Only respond in commit comments +@gh.mention(username="commit-bot", scope=MentionScope.COMMIT) +``` + +Scope mappings: +- `MentionScope.ISSUE`: Issue comments only +- `MentionScope.PR`: PR comments, PR reviews, and PR review comments +- `MentionScope.COMMIT`: Commit comments only + +#### Parsing Rules + +The mention parser follows GitHub's rules: + +- **Valid mentions**: Must start with `@` followed by a GitHub username +- **Username format**: 1-39 characters, alphanumeric or single hyphens, no consecutive hyphens +- **Position**: Must be preceded by whitespace or start of line +- **Exclusions**: Mentions in code blocks, inline code, or blockquotes are ignored + +Examples: +``` +@bot help ✓ Detected +Hey @bot can you help? ✓ Detected +@deploy-bot start ✓ Detected +See @user's comment ✓ Detected + +email@example.com ✗ Not a mention +@@bot ✗ Invalid format +`@bot help` ✗ Inside code +```@bot in code``` ✗ Inside code block +> @bot quoted ✗ Inside blockquote +``` + +#### Multiple Mentions + +When a comment contains multiple mentions, each matching mention triggers a separate handler call: + +```python +@gh.mention(username=re.compile(r".*-bot")) +async def handle_bot_mention(event, gh, *args, context, **kwargs): + mention = context.mention + + # For comment: "@deploy-bot start @test-bot validate @user check" + # This handler is called twice: + # 1. For @deploy-bot (mention.username = "deploy-bot") + # 2. For @test-bot (mention.username = "test-bot") + # The @user mention is filtered out by the regex pattern +``` + ### System Checks The library includes Django system checks to validate your webhook configuration: From 71249f5769032a2e69a9368f876e77bce01dd32e Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 25 Jul 2025 13:18:45 -0500 Subject: [PATCH 31/35] update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fd1f5f..924be69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/ ## [Unreleased] +### Added + +- Added `@gh.mention` decorator for handling GitHub mentions in comments. Supports filtering by username pattern (exact match or regex) and scope (issues, PRs, or commits). + ## [0.7.0] ### Added From d3a8baba82ae3d9a1b38a19960d47cc08245f83d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 25 Jul 2025 18:18:52 +0000 Subject: [PATCH 32/35] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index f3b45cc..b6e718b 100644 --- a/README.md +++ b/README.md @@ -337,7 +337,7 @@ async def handle_bot_mention(event, gh, *args, context, **kwargs): """Respond when someone mentions @mybot""" mention = context.mention issue_url = event.data["issue"]["comments_url"] - + await gh.post( issue_url, data={"body": f"Hello! You mentioned me at position {mention.position}"} @@ -379,7 +379,7 @@ def handle_bot_mention(event, gh, *args, context, **kwargs): """Respond when someone mentions @mybot""" mention = context.mention issue_url = event.data["issue"]["comments_url"] - + gh.post( issue_url, data={"body": f"Hello! You mentioned me at position {mention.position}"} @@ -578,19 +578,19 @@ Each handler receives a `context` parameter with detailed information about the @gh.mention(username="mybot") async def handle_mention(event, gh, *args, context, **kwargs): mention = context.mention - + # Access mention details print(f"Username: {mention.username}") # "mybot" print(f"Position: {mention.position}") # Character position in comment print(f"Line: {mention.line_info.lineno}") # Line number (1-based) print(f"Line text: {mention.line_info.text}") # Full text of the line - + # Navigate between mentions in the same comment if mention.previous_mention: print(f"Previous: @{mention.previous_mention.username}") if mention.next_mention: print(f"Next: @{mention.next_mention.username}") - + # Check the scope (ISSUE, PR, or COMMIT) print(f"Scope: {context.scope}") ``` @@ -646,7 +646,7 @@ The mention parser follows GitHub's rules: Examples: ``` @bot help ✓ Detected -Hey @bot can you help? ✓ Detected +Hey @bot can you help? ✓ Detected @deploy-bot start ✓ Detected See @user's comment ✓ Detected @@ -665,7 +665,7 @@ When a comment contains multiple mentions, each matching mention triggers a sepa @gh.mention(username=re.compile(r".*-bot")) async def handle_bot_mention(event, gh, *args, context, **kwargs): mention = context.mention - + # For comment: "@deploy-bot start @test-bot validate @user check" # This handler is called twice: # 1. For @deploy-bot (mention.username = "deploy-bot") From 28e6c6694c7eeef4956b232c4ae4f7ec841b9c14 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 25 Jul 2025 13:22:30 -0500 Subject: [PATCH 33/35] blacken and lint readme --- README.md | 49 +++++++++++++++++++++++++++++++++---------------- 1 file changed, 33 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index f3b45cc..df60d31 100644 --- a/README.md +++ b/README.md @@ -337,12 +337,13 @@ async def handle_bot_mention(event, gh, *args, context, **kwargs): """Respond when someone mentions @mybot""" mention = context.mention issue_url = event.data["issue"]["comments_url"] - + await gh.post( issue_url, - data={"body": f"Hello! You mentioned me at position {mention.position}"} + data={"body": f"Hello! You mentioned me at position {mention.position}"}, ) + # Use regex to match multiple bot names @gh.mention(username=re.compile(r".*-bot")) async def handle_any_bot(event, gh, *args, context, **kwargs): @@ -350,16 +351,16 @@ async def handle_any_bot(event, gh, *args, context, **kwargs): mention = context.mention await gh.post( event.data["issue"]["comments_url"], - data={"body": f"Bot {mention.username} at your service!"} + data={"body": f"Bot {mention.username} at your service!"}, ) + # Restrict to pull request mentions only @gh.mention(username="deploy-bot", scope=MentionScope.PR) async def handle_deploy_command(event, gh, *args, context, **kwargs): """Only respond to @deploy-bot in pull requests""" await gh.post( - event.data["issue"]["comments_url"], - data={"body": "Starting deployment..."} + event.data["issue"]["comments_url"], data={"body": "Starting deployment..."} ) ``` @@ -379,10 +380,10 @@ def handle_bot_mention(event, gh, *args, context, **kwargs): """Respond when someone mentions @mybot""" mention = context.mention issue_url = event.data["issue"]["comments_url"] - + gh.post( issue_url, - data={"body": f"Hello! You mentioned me at position {mention.position}"} + data={"body": f"Hello! You mentioned me at position {mention.position}"}, ) ``` @@ -578,19 +579,19 @@ Each handler receives a `context` parameter with detailed information about the @gh.mention(username="mybot") async def handle_mention(event, gh, *args, context, **kwargs): mention = context.mention - + # Access mention details - print(f"Username: {mention.username}") # "mybot" - print(f"Position: {mention.position}") # Character position in comment - print(f"Line: {mention.line_info.lineno}") # Line number (1-based) - print(f"Line text: {mention.line_info.text}") # Full text of the line - + print(f"Username: {mention.username}") # "mybot" + print(f"Position: {mention.position}") # Character position in comment + print(f"Line: {mention.line_info.lineno}") # Line number (1-based) + print(f"Line text: {mention.line_info.text}") # Full text of the line + # Navigate between mentions in the same comment if mention.previous_mention: print(f"Previous: @{mention.previous_mention.username}") if mention.next_mention: print(f"Next: @{mention.next_mention.username}") - + # Check the scope (ISSUE, PR, or COMMIT) print(f"Scope: {context.scope}") ``` @@ -604,12 +605,20 @@ Filter mentions by username using exact matches or regular expressions: ```python # Exact match (case-insensitive) @gh.mention(username="deploy-bot") +def exact_match_mention(): + ... + # Regular expression pattern @gh.mention(username=re.compile(r".*-bot")) +def regex_mention(): + ... + # Respond to all mentions (no filter) @gh.mention() +def all_mentions(): + ... ``` ##### Scopes @@ -621,12 +630,20 @@ from django_github_app.mentions import MentionScope # Only respond in issues (not PRs) @gh.mention(username="issue-bot", scope=MentionScope.ISSUE) +def issue_mention(): + ... + # Only respond in pull requests @gh.mention(username="review-bot", scope=MentionScope.PR) +def pull_request_mention(): + ... + # Only respond in commit comments @gh.mention(username="commit-bot", scope=MentionScope.COMMIT) +def commit_mention(): + ... ``` Scope mappings: @@ -646,7 +663,7 @@ The mention parser follows GitHub's rules: Examples: ``` @bot help ✓ Detected -Hey @bot can you help? ✓ Detected +Hey @bot can you help? ✓ Detected @deploy-bot start ✓ Detected See @user's comment ✓ Detected @@ -665,7 +682,7 @@ When a comment contains multiple mentions, each matching mention triggers a sepa @gh.mention(username=re.compile(r".*-bot")) async def handle_bot_mention(event, gh, *args, context, **kwargs): mention = context.mention - + # For comment: "@deploy-bot start @test-bot validate @user check" # This handler is called twice: # 1. For @deploy-bot (mention.username = "deploy-bot") From e2fe6a4c2d5a447414592475cb1bd4e7f9e1da9a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 25 Jul 2025 18:22:47 +0000 Subject: [PATCH 34/35] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 21a9116..df60d31 100644 --- a/README.md +++ b/README.md @@ -581,10 +581,10 @@ async def handle_mention(event, gh, *args, context, **kwargs): mention = context.mention # Access mention details - print(f"Username: {mention.username}") # "mybot" - print(f"Position: {mention.position}") # Character position in comment - print(f"Line: {mention.line_info.lineno}") # Line number (1-based) - print(f"Line text: {mention.line_info.text}") # Full text of the line + print(f"Username: {mention.username}") # "mybot" + print(f"Position: {mention.position}") # Character position in comment + print(f"Line: {mention.line_info.lineno}") # Line number (1-based) + print(f"Line text: {mention.line_info.text}") # Full text of the line # Navigate between mentions in the same comment if mention.previous_mention: From 3554ad480d7f99370d752709bd4174df14d0816a Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 25 Jul 2025 14:00:08 -0500 Subject: [PATCH 35/35] tweak --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index df60d31..92e7b0a 100644 --- a/README.md +++ b/README.md @@ -563,15 +563,13 @@ The library loads either async or sync versions of these handlers based on your The `@gh.mention` decorator provides a powerful way to build interactive GitHub Apps that respond to mentions in comments. When users mention your app (e.g., `@mybot help`), the decorator automatically detects these mentions and routes them to your handlers. -#### How It Works - The mention system: 1. Monitors incoming webhook events for comments containing mentions 2. Extracts all mentions while ignoring those in code blocks, inline code, or blockquotes 3. Filters mentions based on your specified criteria (username pattern, scope) 4. Calls your handler once for each matching mention, providing rich context -#### Mention Context +#### Context Each handler receives a `context` parameter with detailed information about the mention: