Skip to content

Commit 267e19f

Browse files
authored
feat: filter irrelevant gitHub events before rule evaluation
2 parents bf5c971 + e496259 commit 267e19f

9 files changed

Lines changed: 353 additions & 12 deletions

File tree

src/core/utils/event_filter.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""
2+
Event filtering for GitHub webhooks.
3+
4+
Centralized logic to skip rule evaluation on irrelevant events:
5+
branch deletions, closed/merged PRs, archived repos, etc.
6+
"""
7+
8+
from dataclasses import dataclass
9+
from typing import Any
10+
11+
import structlog
12+
13+
from src.core.models import EventType, WebhookEvent
14+
15+
logger = structlog.get_logger()
16+
17+
NULL_SHA = "0000000000000000000000000000000000000000"
18+
19+
PR_ACTIONS_PROCESS = frozenset({"opened", "synchronize", "reopened"})
20+
21+
22+
@dataclass(frozen=True)
23+
class FilterResult:
24+
"""Result of event filter check."""
25+
26+
should_process: bool
27+
reason: str = ""
28+
29+
30+
def should_process_event(event: WebhookEvent) -> FilterResult:
31+
"""
32+
Determine if an event should trigger rule evaluation.
33+
34+
Returns FilterResult with should_process=True to process, False to skip.
35+
Logs filtered events for observability.
36+
"""
37+
payload = event.payload
38+
event_type = event.event_type
39+
40+
result = _apply_filters(event_type, payload)
41+
if not result.should_process:
42+
logger.info(
43+
"event_filtered",
44+
event_type=event_type.value if hasattr(event_type, "value") else str(event_type),
45+
repo=event.repo_full_name,
46+
reason=result.reason,
47+
)
48+
return result
49+
50+
51+
def _apply_filters(event_type: EventType, payload: dict[str, Any]) -> FilterResult:
52+
if _is_repo_archived(payload):
53+
return FilterResult(should_process=False, reason="Repository is archived")
54+
55+
if event_type == EventType.PULL_REQUEST:
56+
return _filter_pull_request(payload)
57+
if event_type == EventType.PUSH:
58+
return _filter_push(payload)
59+
return FilterResult(should_process=True)
60+
61+
62+
def _filter_pull_request(payload: dict[str, Any]) -> FilterResult:
63+
action = payload.get("action")
64+
if action not in PR_ACTIONS_PROCESS:
65+
return FilterResult(should_process=False, reason=f"PR action '{action}' not processed")
66+
67+
pr = payload.get("pull_request", {})
68+
state = pr.get("state", "")
69+
if state != "open":
70+
return FilterResult(should_process=False, reason=f"PR state '{state}' not open")
71+
72+
if pr.get("merged"):
73+
return FilterResult(should_process=False, reason="PR already merged")
74+
75+
if pr.get("draft"):
76+
return FilterResult(should_process=False, reason="PR is draft")
77+
78+
return FilterResult(should_process=True)
79+
80+
81+
def _filter_push(payload: dict[str, Any]) -> FilterResult:
82+
if payload.get("deleted"):
83+
return FilterResult(should_process=False, reason="Branch deletion event")
84+
85+
after = payload.get("after")
86+
if not after or after == NULL_SHA:
87+
return FilterResult(should_process=False, reason="No valid commit SHA (deleted or empty push)")
88+
89+
return FilterResult(should_process=True)
90+
91+
92+
def _is_repo_archived(payload: dict[str, Any]) -> bool:
93+
repo = payload.get("repository", {})
94+
return isinstance(repo, dict) and bool(repo.get("archived"))

src/event_processors/pull_request/processor.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,20 @@ async def process(self, task: Task) -> ProcessingResult:
4848
error="No installation ID found",
4949
)
5050

51+
if pr_data.get("state") == "closed" or pr_data.get("merged") or pr_data.get("draft"):
52+
logger.info(
53+
"pr_skipped_invalid_state",
54+
state=pr_data.get("state"),
55+
merged=pr_data.get("merged"),
56+
draft=pr_data.get("draft"),
57+
)
58+
return ProcessingResult(
59+
success=True,
60+
violations=[],
61+
api_calls_made=0,
62+
processing_time_ms=int((time.time() - start_time) * 1000),
63+
)
64+
5165
try:
5266
logger.info("=" * 80)
5367
logger.info(f"🚀 Processing PR event for {repo_full_name}")

src/event_processors/push.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from src.agents import get_agent
66
from src.core.models import Severity, Violation
7+
from src.core.utils.event_filter import NULL_SHA
78
from src.event_processors.base import BaseEventProcessor, ProcessingResult
89
from src.integrations.github.check_runs import CheckRunManager
910
from src.tasks.task_queue import Task
@@ -37,6 +38,15 @@ async def process(self, task: Task) -> ProcessingResult:
3738
logger.info(f" Commits: {len(commits)}")
3839
logger.info("=" * 80)
3940

41+
if payload.get("deleted") or not payload.get("after") or payload.get("after") == NULL_SHA:
42+
logger.info("push_skipped_deleted_or_empty")
43+
return ProcessingResult(
44+
success=True,
45+
violations=[],
46+
api_calls_made=0,
47+
processing_time_ms=int((time.time() - start_time) * 1000),
48+
)
49+
4050
event_data = {
4151
"push": {
4252
"ref": ref,

src/webhooks/dispatcher.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import structlog
55

66
from src.core.models import EventType, WebhookEvent
7+
from src.core.utils.event_filter import should_process_event
78
from src.tasks.task_queue import TaskQueue, task_queue
89

910
logger = structlog.get_logger()
@@ -41,6 +42,10 @@ async def dispatch(self, event: WebhookEvent) -> dict[str, Any]:
4142
log.warning("handler_not_found")
4243
return {"status": "skipped", "reason": f"No handler for event type {event_type}"}
4344

45+
filter_result = should_process_event(event)
46+
if not filter_result.should_process:
47+
return {"status": "filtered", "reason": filter_result.reason, "event_type": event_type}
48+
4449
# Offload to TaskQueue for background execution (delivery_id so each webhook delivery is processed)
4550
success = await self.queue.enqueue(handler, event_type, event.payload, event, delivery_id=event.delivery_id)
4651

src/webhooks/handlers/pull_request.py

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,6 @@ async def handle(self, event: WebhookEvent) -> WebhookResponse:
3333
pr_number=event.payload.get("pull_request", {}).get("number"),
3434
action=event.payload.get("action"),
3535
)
36-
37-
# Filter relevant actions to reduce noise (optional but good practice)
38-
action = event.payload.get("action")
39-
if action not in ["opened", "synchronize", "reopened", "edited"]:
40-
log.info("pr_action_ignored", action=action)
41-
return WebhookResponse(
42-
status="ignored", detail=f"PR action '{action}' is not processed", event_type=EventType.PULL_REQUEST
43-
)
44-
4536
log.info("pr_handler_invoked")
4637

4738
try:

tests/integration/webhooks/test_webhook_flow.py

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ def fresh_queue() -> TaskQueue:
3636

3737
@pytest.fixture
3838
def valid_pr_payload() -> dict[str, object]:
39-
"""Valid pull request webhook payload."""
39+
"""Valid pull request webhook payload (open PR, passes event filter)."""
4040
return {
4141
"action": "opened",
4242
"sender": {"login": "octocat", "id": 1, "type": "User"},
@@ -47,7 +47,14 @@ def valid_pr_payload() -> dict[str, object]:
4747
"private": False,
4848
"html_url": "https://github.com/octocat/watchflow",
4949
},
50-
"pull_request": {"number": 42, "title": "Test PR", "body": "Test body"},
50+
"pull_request": {
51+
"number": 42,
52+
"title": "Test PR",
53+
"body": "Test body",
54+
"state": "open",
55+
"merged": False,
56+
"draft": False,
57+
},
5158
}
5259

5360

@@ -157,7 +164,9 @@ async def test_multiple_event_types_flow(
157164
"html_url": "https://github.com/octocat/watchflow",
158165
},
159166
"ref": "refs/heads/main",
160-
"commits": [],
167+
"deleted": False,
168+
"after": "abc123def456",
169+
"commits": [{"id": "abc123def456"}],
161170
}
162171

163172
with patch("src.webhooks.router.dispatcher", fresh_dispatcher):
@@ -223,6 +232,49 @@ async def test_handler_exception_doesnt_break_flow(
223232
# Handler was called and exception was caught
224233
assert failing_handler.called
225234

235+
@pytest.mark.asyncio
236+
@respx.mock
237+
async def test_filtered_event_not_dispatched(
238+
self,
239+
app: FastAPI,
240+
fresh_dispatcher: WebhookDispatcher,
241+
fresh_queue: TaskQueue,
242+
valid_headers: dict[str, str],
243+
) -> None:
244+
"""Test that filtered events (e.g. branch deletion) are not dispatched."""
245+
mock_handler = AsyncMock()
246+
fresh_dispatcher.register_handler("push", mock_handler)
247+
await fresh_queue.start_workers()
248+
249+
deleted_branch_payload = {
250+
"sender": {"login": "octocat", "id": 1, "type": "User"},
251+
"repository": {
252+
"id": 123456,
253+
"name": "watchflow",
254+
"full_name": "octocat/watchflow",
255+
"private": False,
256+
"html_url": "https://github.com/octocat/watchflow",
257+
},
258+
"ref": "refs/heads/feature",
259+
"deleted": True,
260+
"after": "0000000000000000000000000000000000000000",
261+
"commits": [],
262+
}
263+
264+
with patch("src.webhooks.router.dispatcher", fresh_dispatcher):
265+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
266+
response = await client.post(
267+
"/webhooks/github",
268+
json=deleted_branch_payload,
269+
headers={**valid_headers, "X-GitHub-Event": "push"},
270+
)
271+
272+
assert response.status_code == 200
273+
await asyncio.sleep(0.1)
274+
await fresh_queue.queue.join()
275+
276+
assert mock_handler.call_count == 0
277+
226278
@pytest.mark.asyncio
227279
@respx.mock
228280
async def test_no_handler_registered_flow(
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
from src.core.models import EventType, WebhookEvent
2+
from src.core.utils.event_filter import should_process_event
3+
4+
5+
def _make_event(event_type: EventType, payload: dict) -> WebhookEvent:
6+
return WebhookEvent(event_type=event_type, payload=payload)
7+
8+
9+
def test_pull_request_opened_processes():
10+
payload = {
11+
"action": "opened",
12+
"repository": {"full_name": "owner/repo"},
13+
"pull_request": {"state": "open", "merged": False, "draft": False},
14+
}
15+
result = should_process_event(_make_event(EventType.PULL_REQUEST, payload))
16+
assert result.should_process is True
17+
18+
19+
def test_pull_request_synchronize_processes():
20+
payload = {
21+
"action": "synchronize",
22+
"repository": {"full_name": "owner/repo"},
23+
"pull_request": {"state": "open", "merged": False, "draft": False},
24+
}
25+
result = should_process_event(_make_event(EventType.PULL_REQUEST, payload))
26+
assert result.should_process is True
27+
28+
29+
def test_pull_request_reopened_processes():
30+
payload = {
31+
"action": "reopened",
32+
"repository": {"full_name": "owner/repo"},
33+
"pull_request": {"state": "open", "merged": False, "draft": False},
34+
}
35+
result = should_process_event(_make_event(EventType.PULL_REQUEST, payload))
36+
assert result.should_process is True
37+
38+
39+
def test_pull_request_closed_action_filtered():
40+
payload = {
41+
"action": "closed",
42+
"repository": {"full_name": "owner/repo"},
43+
"pull_request": {"state": "closed", "merged": True},
44+
}
45+
result = should_process_event(_make_event(EventType.PULL_REQUEST, payload))
46+
assert result.should_process is False
47+
assert "closed" in result.reason or "not processed" in result.reason
48+
49+
50+
def test_pull_request_merged_filtered():
51+
payload = {
52+
"action": "opened",
53+
"repository": {"full_name": "owner/repo"},
54+
"pull_request": {"state": "closed", "merged": True, "draft": False},
55+
}
56+
result = should_process_event(_make_event(EventType.PULL_REQUEST, payload))
57+
assert result.should_process is False
58+
assert "merged" in result.reason or "not open" in result.reason
59+
60+
61+
def test_pull_request_draft_filtered():
62+
payload = {
63+
"action": "opened",
64+
"repository": {"full_name": "owner/repo"},
65+
"pull_request": {"state": "open", "merged": False, "draft": True},
66+
}
67+
result = should_process_event(_make_event(EventType.PULL_REQUEST, payload))
68+
assert result.should_process is False
69+
assert "draft" in result.reason
70+
71+
72+
def test_push_valid_processes():
73+
payload = {
74+
"repository": {"full_name": "owner/repo"},
75+
"ref": "refs/heads/main",
76+
"deleted": False,
77+
"after": "abc123",
78+
"commits": [{}],
79+
}
80+
result = should_process_event(_make_event(EventType.PUSH, payload))
81+
assert result.should_process is True
82+
83+
84+
def test_push_deleted_branch_filtered():
85+
payload = {
86+
"repository": {"full_name": "owner/repo"},
87+
"ref": "refs/heads/feature",
88+
"deleted": True,
89+
"after": "0000000000000000000000000000000000000000",
90+
}
91+
result = should_process_event(_make_event(EventType.PUSH, payload))
92+
assert result.should_process is False
93+
assert "deletion" in result.reason or "Branch" in result.reason
94+
95+
96+
def test_push_null_sha_filtered():
97+
payload = {
98+
"repository": {"full_name": "owner/repo"},
99+
"ref": "refs/heads/main",
100+
"deleted": False,
101+
"after": "0000000000000000000000000000000000000000",
102+
}
103+
result = should_process_event(_make_event(EventType.PUSH, payload))
104+
assert result.should_process is False
105+
106+
107+
def test_push_empty_after_filtered():
108+
payload = {
109+
"repository": {"full_name": "owner/repo"},
110+
"ref": "refs/heads/main",
111+
"deleted": False,
112+
"after": "",
113+
}
114+
result = should_process_event(_make_event(EventType.PUSH, payload))
115+
assert result.should_process is False
116+
117+
118+
def test_archived_repo_filtered():
119+
payload = {
120+
"repository": {"full_name": "owner/repo", "archived": True},
121+
"action": "opened",
122+
"pull_request": {"state": "open", "merged": False, "draft": False},
123+
}
124+
result = should_process_event(_make_event(EventType.PULL_REQUEST, payload))
125+
assert result.should_process is False
126+
assert "archived" in result.reason
127+
128+
129+
def test_other_event_types_process():
130+
payload = {"repository": {"full_name": "owner/repo"}}
131+
for evt in (EventType.CHECK_RUN, EventType.DEPLOYMENT, EventType.DEPLOYMENT_STATUS):
132+
result = should_process_event(_make_event(evt, payload))
133+
assert result.should_process is True

0 commit comments

Comments
 (0)