Skip to content

Commit 9ef1e05

Browse files
Refactor mention system to use explicit re.Pattern API
1 parent c6fe62a commit 9ef1e05

File tree

5 files changed

+1272
-249
lines changed

5 files changed

+1272
-249
lines changed

src/django_github_app/mentions.py

Lines changed: 181 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22

33
import re
44
from dataclasses import dataclass
5+
from datetime import datetime
56
from enum import Enum
67
from typing import NamedTuple
78

9+
from django.conf import settings
10+
from django.utils import timezone
811
from gidgethub import sansio
912

1013
from .permissions import Permission
@@ -46,77 +49,208 @@ def all_events(cls) -> list[EventAction]:
4649
)
4750

4851

52+
@dataclass
53+
class Mention:
54+
username: str
55+
text: str
56+
position: int
57+
line_number: int
58+
line_text: str
59+
match: re.Match[str] | None = None
60+
previous_mention: Mention | None = None
61+
next_mention: Mention | None = None
62+
63+
64+
@dataclass
65+
class Comment:
66+
body: str
67+
author: str
68+
created_at: datetime
69+
url: str
70+
mentions: list[Mention]
71+
72+
@property
73+
def line_count(self) -> int:
74+
"""Number of lines in the comment."""
75+
if not self.body:
76+
return 0
77+
return len(self.body.splitlines())
78+
79+
@classmethod
80+
def from_event(cls, event: sansio.Event) -> Comment:
81+
match event.event:
82+
case "issue_comment" | "pull_request_review_comment" | "commit_comment":
83+
comment_data = event.data.get("comment")
84+
case "pull_request_review":
85+
comment_data = event.data.get("review")
86+
case _:
87+
comment_data = None
88+
89+
if not comment_data:
90+
raise ValueError(f"Cannot extract comment from event type: {event.event}")
91+
92+
created_at_str = comment_data.get("created_at", "")
93+
if created_at_str:
94+
# GitHub timestamps are in ISO format: 2024-01-01T12:00:00Z
95+
created_at_aware = datetime.fromisoformat(
96+
created_at_str.replace("Z", "+00:00")
97+
)
98+
if settings.USE_TZ:
99+
created_at = created_at_aware
100+
else:
101+
created_at = timezone.make_naive(
102+
created_at_aware, timezone.get_default_timezone()
103+
)
104+
else:
105+
created_at = timezone.now()
106+
107+
author = comment_data.get("user", {}).get("login", "")
108+
if not author and "sender" in event.data:
109+
author = event.data.get("sender", {}).get("login", "")
110+
111+
return cls(
112+
body=comment_data.get("body", ""),
113+
author=author,
114+
created_at=created_at,
115+
url=comment_data.get("html_url", ""),
116+
mentions=[],
117+
)
118+
119+
49120
@dataclass
50121
class MentionContext:
51-
commands: list[str]
122+
comment: Comment
123+
triggered_by: Mention
52124
user_permission: Permission | None
53125
scope: MentionScope | None
54126

55127

56-
class MentionMatch(NamedTuple):
57-
mention: str
58-
command: str | None
59-
60-
61128
CODE_BLOCK_PATTERN = re.compile(r"```[\s\S]*?```", re.MULTILINE)
62129
INLINE_CODE_PATTERN = re.compile(r"`[^`]+`")
63130
QUOTE_PATTERN = re.compile(r"^\s*>.*$", re.MULTILINE)
64131

65132

66-
def parse_mentions(event: sansio.Event, username: str) -> list[MentionMatch]:
67-
text = event.data.get("comment", {}).get("body", "")
133+
def get_event_scope(event: sansio.Event) -> MentionScope | None:
134+
if event.event == "issue_comment":
135+
issue = event.data.get("issue", {})
136+
is_pull_request = "pull_request" in issue and issue["pull_request"] is not None
137+
return MentionScope.PR if is_pull_request else MentionScope.ISSUE
68138

69-
if not text:
70-
return []
139+
for scope in MentionScope:
140+
scope_events = scope.get_events()
141+
if any(event_action.event == event.event for event_action in scope_events):
142+
return scope
71143

72-
text = CODE_BLOCK_PATTERN.sub(lambda m: " " * len(m.group(0)), text)
73-
text = INLINE_CODE_PATTERN.sub(lambda m: " " * len(m.group(0)), text)
74-
text = QUOTE_PATTERN.sub(lambda m: " " * len(m.group(0)), text)
144+
return None
75145

76-
username_pattern = re.compile(
77-
rf"(?:^|(?<=\s))(@{re.escape(username)})(?:\s+([\w\-?]+))?(?=\s|$|[^\w\-])",
78-
re.MULTILINE | re.IGNORECASE,
79-
)
80146

81-
mentions: list[MentionMatch] = []
82-
for match in username_pattern.finditer(text):
83-
mention = match.group(1) # @username
84-
command = match.group(2) # optional command
85-
mentions.append(
86-
MentionMatch(mention=mention, command=command.lower() if command else None)
87-
)
147+
def check_pattern_match(
148+
text: str, pattern: str | re.Pattern[str] | None
149+
) -> re.Match[str] | None:
150+
"""Check if text matches the given pattern (string or regex).
88151
89-
return mentions
152+
Returns Match object if pattern matches, None otherwise.
153+
If pattern is None, returns a dummy match object.
154+
"""
155+
if pattern is None:
156+
return re.match(r"(.*)", text, re.IGNORECASE | re.DOTALL)
90157

158+
# Check if it's a compiled regex pattern
159+
if isinstance(pattern, re.Pattern):
160+
# Use the pattern directly, preserving its flags
161+
return pattern.match(text)
91162

92-
def get_commands(event: sansio.Event, username: str) -> list[str]:
93-
mentions = parse_mentions(event, username)
94-
return [m.command for m in mentions if m.command]
163+
# For strings, do exact match (case-insensitive)
164+
# Escape the string to treat it literally
165+
escaped_pattern = re.escape(pattern)
166+
return re.match(escaped_pattern, text, re.IGNORECASE)
95167

96168

97-
def check_event_for_mention(
98-
event: sansio.Event, command: str | None, username: str
99-
) -> bool:
100-
mentions = parse_mentions(event, username)
169+
def parse_mentions_for_username(
170+
event: sansio.Event, username_pattern: str | re.Pattern[str] | None = None
171+
) -> list[Mention]:
172+
body = event.data.get("comment", {}).get("body", "")
101173

102-
if not mentions:
103-
return False
174+
if not body:
175+
return []
104176

105-
if not command:
106-
return True
177+
# If no pattern specified, use bot username (TODO: get from settings)
178+
if username_pattern is None:
179+
username_pattern = "bot" # Placeholder
180+
181+
# Handle regex patterns vs literal strings
182+
if isinstance(username_pattern, re.Pattern):
183+
# Use the pattern string directly, preserving any flags
184+
username_regex = username_pattern.pattern
185+
# Extract flags from the compiled pattern
186+
flags = username_pattern.flags | re.MULTILINE | re.IGNORECASE
187+
else:
188+
# For strings, escape them to be treated literally
189+
username_regex = re.escape(username_pattern)
190+
flags = re.MULTILINE | re.IGNORECASE
191+
192+
original_body = body
193+
original_lines = original_body.splitlines()
194+
195+
processed_text = CODE_BLOCK_PATTERN.sub(lambda m: " " * len(m.group(0)), body)
196+
processed_text = INLINE_CODE_PATTERN.sub(
197+
lambda m: " " * len(m.group(0)), processed_text
198+
)
199+
processed_text = QUOTE_PATTERN.sub(lambda m: " " * len(m.group(0)), processed_text)
107200

108-
return any(mention.command == command.lower() for mention in mentions)
201+
# Use \S+ to match non-whitespace characters for username
202+
# Special handling for patterns that could match too broadly
203+
if ".*" in username_regex:
204+
# Replace .* with a more specific pattern that won't match spaces or @
205+
username_regex = username_regex.replace(".*", r"[^@\s]*")
109206

207+
mention_pattern = re.compile(
208+
rf"(?:^|(?<=\s))@({username_regex})(?:\s|$|(?=[^\w\-]))",
209+
flags,
210+
)
110211

111-
def get_event_scope(event: sansio.Event) -> MentionScope | None:
112-
if event.event == "issue_comment":
113-
issue = event.data.get("issue", {})
114-
is_pull_request = "pull_request" in issue and issue["pull_request"] is not None
115-
return MentionScope.PR if is_pull_request else MentionScope.ISSUE
212+
mentions: list[Mention] = []
116213

117-
for scope in MentionScope:
118-
scope_events = scope.get_events()
119-
if any(event_action.event == event.event for event_action in scope_events):
120-
return scope
214+
for match in mention_pattern.finditer(processed_text):
215+
position = match.start() # Position of @
216+
username = match.group(1) # Captured username
121217

122-
return None
218+
text_before = original_body[:position]
219+
line_number = text_before.count("\n") + 1
220+
221+
line_index = line_number - 1
222+
line_text = (
223+
original_lines[line_index] if line_index < len(original_lines) else ""
224+
)
225+
226+
text_start = match.end()
227+
228+
# Find next @mention to know where this text ends
229+
next_match = mention_pattern.search(processed_text, match.end())
230+
if next_match:
231+
text_end = next_match.start()
232+
else:
233+
text_end = len(original_body)
234+
235+
text = original_body[text_start:text_end].strip()
236+
237+
mention = Mention(
238+
username=username,
239+
text=text,
240+
position=position,
241+
line_number=line_number,
242+
line_text=line_text,
243+
match=None,
244+
previous_mention=None,
245+
next_mention=None,
246+
)
247+
248+
mentions.append(mention)
249+
250+
for i, mention in enumerate(mentions):
251+
if i > 0:
252+
mention.previous_mention = mentions[i - 1]
253+
if i < len(mentions) - 1:
254+
mention.next_mention = mentions[i + 1]
255+
256+
return mentions

0 commit comments

Comments
 (0)