Skip to content

Commit 86ba7b2

Browse files
Add mention parsing and command extraction logic
1 parent 864d0ae commit 86ba7b2

File tree

4 files changed

+268
-22
lines changed

4 files changed

+268
-22
lines changed

src/django_github_app/commands.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from __future__ import annotations
22

3+
import re
34
from enum import Enum
5+
from typing import Any
46
from typing import NamedTuple
57

68

@@ -39,3 +41,51 @@ def all_events(cls) -> list[EventAction]:
3941
)
4042
)
4143

44+
45+
class MentionMatch(NamedTuple):
46+
mention: str
47+
command: str | None
48+
49+
50+
CODE_BLOCK_PATTERN = re.compile(r"```[\s\S]*?```", re.MULTILINE)
51+
INLINE_CODE_PATTERN = re.compile(r"`[^`]+`")
52+
QUOTE_PATTERN = re.compile(r"^\s*>.*$", re.MULTILINE)
53+
54+
55+
def parse_mentions(text: str, username: str) -> list[MentionMatch]:
56+
if not text:
57+
return []
58+
59+
text = CODE_BLOCK_PATTERN.sub(lambda m: " " * len(m.group(0)), text)
60+
text = INLINE_CODE_PATTERN.sub(lambda m: " " * len(m.group(0)), text)
61+
text = QUOTE_PATTERN.sub(lambda m: " " * len(m.group(0)), text)
62+
63+
username_pattern = re.compile(
64+
rf"(?:^|(?<=\s))(@{re.escape(username)})(?:\s+([\w\-?]+))?(?=\s|$|[^\w\-])",
65+
re.MULTILINE | re.IGNORECASE,
66+
)
67+
68+
mentions: list[MentionMatch] = []
69+
for match in username_pattern.finditer(text):
70+
mention = match.group(1) # @username
71+
command = match.group(2) # optional command
72+
mentions.append(
73+
MentionMatch(mention=mention, command=command.lower() if command else None)
74+
)
75+
76+
return mentions
77+
78+
79+
def check_event_for_mention(
80+
event: dict[str, Any], command: str | None, username: str
81+
) -> bool:
82+
comment = event.get("comment", {}).get("body", "")
83+
mentions = parse_mentions(comment, username)
84+
85+
if not mentions:
86+
return False
87+
88+
if not command:
89+
return True
90+
91+
return any(mention.command == command.lower() for mention in mentions)

src/django_github_app/routing.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
from ._typing import override
1717
from .commands import CommandScope
18+
from .commands import check_event_for_mention
1819

1920
AsyncCallback = Callable[..., Awaitable[None]]
2021
SyncCallback = Callable[..., None]
@@ -76,8 +77,12 @@ def decorator(func: CB) -> CB:
7677
async def async_wrapper(
7778
event: sansio.Event, *args: Any, **wrapper_kwargs: Any
7879
) -> None:
79-
# TODO: Parse comment body for mentions
80-
# TODO: If command specified, check if it matches
80+
# TODO: Get actual bot username from installation/app data
81+
username = "bot" # Placeholder
82+
83+
if not check_event_for_mention(event.data, command, username):
84+
return
85+
8186
# TODO: Check permissions
8287
# For now, just call through
8388
await func(event, *args, **wrapper_kwargs) # type: ignore[func-returns-value]
@@ -86,8 +91,12 @@ async def async_wrapper(
8691
def sync_wrapper(
8792
event: sansio.Event, *args: Any, **wrapper_kwargs: Any
8893
) -> None:
89-
# TODO: Parse comment body for mentions
90-
# TODO: If command specified, check if it matches
94+
# TODO: Get actual bot username from installation/app data
95+
username = "bot" # Placeholder
96+
97+
if not check_event_for_mention(event.data, command, username):
98+
return
99+
91100
# TODO: Check permissions
92101
# For now, just call through
93102
func(event, *args, **wrapper_kwargs)
@@ -104,7 +113,9 @@ def sync_wrapper(
104113

105114
events = scope.get_events() if scope else CommandScope.all_events()
106115
for event_action in events:
107-
self.add(wrapper, event_action.event, action=event_action.action, **kwargs)
116+
self.add(
117+
wrapper, event_action.event, action=event_action.action, **kwargs
118+
)
108119

109120
return func
110121

tests/test_commands.py

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
from __future__ import annotations
2+
3+
from django_github_app.commands import check_event_for_mention
4+
from django_github_app.commands import parse_mentions
5+
6+
7+
class TestParseMentions:
8+
def test_simple_mention_with_command(self):
9+
text = "@mybot help"
10+
mentions = parse_mentions(text, "mybot")
11+
12+
assert len(mentions) == 1
13+
assert mentions[0].mention == "@mybot"
14+
assert mentions[0].command == "help"
15+
16+
def test_mention_without_command(self):
17+
text = "@mybot"
18+
mentions = parse_mentions(text, "mybot")
19+
20+
assert len(mentions) == 1
21+
assert mentions[0].mention == "@mybot"
22+
assert mentions[0].command is None
23+
24+
def test_case_insensitive_matching(self):
25+
text = "@MyBot help"
26+
mentions = parse_mentions(text, "mybot")
27+
28+
assert len(mentions) == 1
29+
assert mentions[0].mention == "@MyBot"
30+
assert mentions[0].command == "help"
31+
32+
def test_command_case_normalization(self):
33+
text = "@mybot HELP"
34+
mentions = parse_mentions(text, "mybot")
35+
36+
assert len(mentions) == 1
37+
assert mentions[0].command == "help"
38+
39+
def test_multiple_mentions(self):
40+
text = "@mybot help and then @mybot deploy"
41+
mentions = parse_mentions(text, "mybot")
42+
43+
assert len(mentions) == 2
44+
assert mentions[0].command == "help"
45+
assert mentions[1].command == "deploy"
46+
47+
def test_ignore_other_mentions(self):
48+
text = "@otheruser help @mybot deploy @someone else"
49+
mentions = parse_mentions(text, "mybot")
50+
51+
assert len(mentions) == 1
52+
assert mentions[0].command == "deploy"
53+
54+
def test_mention_in_code_block(self):
55+
text = """
56+
Here's some text
57+
```
58+
@mybot help
59+
```
60+
@mybot deploy
61+
"""
62+
mentions = parse_mentions(text, "mybot")
63+
64+
assert len(mentions) == 1
65+
assert mentions[0].command == "deploy"
66+
67+
def test_mention_in_inline_code(self):
68+
text = "Use `@mybot help` for help, or just @mybot deploy"
69+
mentions = parse_mentions(text, "mybot")
70+
71+
assert len(mentions) == 1
72+
assert mentions[0].command == "deploy"
73+
74+
def test_mention_in_quote(self):
75+
text = """
76+
> @mybot help
77+
@mybot deploy
78+
"""
79+
mentions = parse_mentions(text, "mybot")
80+
81+
assert len(mentions) == 1
82+
assert mentions[0].command == "deploy"
83+
84+
def test_empty_text(self):
85+
mentions = parse_mentions("", "mybot")
86+
87+
assert mentions == []
88+
89+
def test_none_text(self):
90+
mentions = parse_mentions(None, "mybot")
91+
92+
assert mentions == []
93+
94+
def test_mention_at_start_of_line(self):
95+
text = "@mybot help"
96+
mentions = parse_mentions(text, "mybot")
97+
98+
assert len(mentions) == 1
99+
assert mentions[0].command == "help"
100+
101+
def test_mention_in_middle_of_text(self):
102+
text = "Hey @mybot help me"
103+
mentions = parse_mentions(text, "mybot")
104+
105+
assert len(mentions) == 1
106+
assert mentions[0].command == "help"
107+
108+
def test_mention_with_punctuation_after(self):
109+
text = "@mybot help!"
110+
mentions = parse_mentions(text, "mybot")
111+
112+
assert len(mentions) == 1
113+
assert mentions[0].command == "help"
114+
115+
def test_hyphenated_username(self):
116+
text = "@my-bot help"
117+
mentions = parse_mentions(text, "my-bot")
118+
119+
assert len(mentions) == 1
120+
assert mentions[0].mention == "@my-bot"
121+
assert mentions[0].command == "help"
122+
123+
def test_underscore_username(self):
124+
text = "@my_bot help"
125+
mentions = parse_mentions(text, "my_bot")
126+
127+
assert len(mentions) == 1
128+
assert mentions[0].mention == "@my_bot"
129+
assert mentions[0].command == "help"
130+
131+
def test_no_space_after_mention(self):
132+
text = "@mybot, please help"
133+
mentions = parse_mentions(text, "mybot")
134+
135+
assert len(mentions) == 1
136+
assert mentions[0].command is None
137+
138+
def test_multiple_spaces_before_command(self):
139+
text = "@mybot help"
140+
mentions = parse_mentions(text, "mybot")
141+
142+
assert len(mentions) == 1
143+
assert mentions[0].command == "help"
144+
145+
def test_hyphenated_command(self):
146+
text = "@mybot async-test"
147+
mentions = parse_mentions(text, "mybot")
148+
149+
assert len(mentions) == 1
150+
assert mentions[0].command == "async-test"
151+
152+
def test_special_character_command(self):
153+
text = "@mybot ?"
154+
mentions = parse_mentions(text, "mybot")
155+
156+
assert len(mentions) == 1
157+
assert mentions[0].command == "?"
158+
159+
160+
class TestCheckMentionMatches:
161+
def test_match_with_command(self):
162+
event = {"comment": {"body": "@bot help"}}
163+
164+
assert check_event_for_mention(event, "help", "bot") is True
165+
assert check_event_for_mention(event, "deploy", "bot") is False
166+
167+
def test_match_without_command(self):
168+
event = {"comment": {"body": "@bot help"}}
169+
170+
assert check_event_for_mention(event, None, "bot") is True
171+
172+
event = {"comment": {"body": "no mention here"}}
173+
174+
assert check_event_for_mention(event, None, "bot") is False
175+
176+
def test_no_comment_body(self):
177+
event = {}
178+
179+
assert check_event_for_mention(event, "help", "bot") is False
180+
181+
event = {"comment": {}}
182+
183+
assert check_event_for_mention(event, "help", "bot") is False
184+
185+
def test_case_insensitive_command_match(self):
186+
event = {"comment": {"body": "@bot HELP"}}
187+
188+
assert check_event_for_mention(event, "help", "bot") is True
189+
assert check_event_for_mention(event, "HELP", "bot") is True
190+
191+
def test_multiple_mentions(self):
192+
event = {"comment": {"body": "@bot help @bot deploy"}}
193+
194+
assert check_event_for_mention(event, "help", "bot") is True
195+
assert check_event_for_mention(event, "deploy", "bot") is True
196+
assert check_event_for_mention(event, "test", "bot") is False

tests/test_routing.py

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,8 @@ def help_command(event, *args, **kwargs):
216216

217217
assert handler_called
218218

219-
def test_multiple_decorators_on_same_function(self, test_router):
219+
@pytest.mark.parametrize("comment", ["@bot help", "@bot h", "@bot ?"])
220+
def test_multiple_decorators_on_same_function(self, comment, test_router):
220221
call_count = 0
221222

222223
@test_router.mention(command="help")
@@ -227,26 +228,14 @@ def help_command(event, *args, **kwargs):
227228
call_count += 1
228229
return f"help called {call_count} times"
229230

230-
event1 = sansio.Event(
231-
{"action": "created", "comment": {"body": "@bot help"}},
231+
event = sansio.Event(
232+
{"action": "created", "comment": {"body": comment}},
232233
event="issue_comment",
233234
delivery_id="123",
234235
)
235-
test_router.dispatch(event1, None)
236-
237-
assert call_count == 3
238-
239-
call_count = 0
240-
event2 = sansio.Event(
241-
{"action": "created", "comment": {"body": "@bot h"}},
242-
event="issue_comment",
243-
delivery_id="124",
244-
)
245-
test_router.dispatch(event2, None)
246-
247-
assert call_count == 3
236+
test_router.dispatch(event, None)
248237

249-
# This behavior will change once we implement command parsing
238+
assert call_count == 1
250239

251240
def test_async_mention_handler(self, test_router):
252241
handler_called = False

0 commit comments

Comments
 (0)