-
Notifications
You must be signed in to change notification settings - Fork 18
feat: filter irrelevant gitHub events before rule evaluation #50
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| """ | ||
| Event filtering for GitHub webhooks. | ||
|
|
||
| Centralized logic to skip rule evaluation on irrelevant events: | ||
| branch deletions, closed/merged PRs, archived repos, etc. | ||
| """ | ||
|
|
||
| from dataclasses import dataclass | ||
| from typing import Any | ||
|
|
||
| import structlog | ||
|
|
||
| from src.core.models import EventType, WebhookEvent | ||
|
|
||
| logger = structlog.get_logger() | ||
|
|
||
| NULL_SHA = "0000000000000000000000000000000000000000" | ||
|
|
||
| PR_ACTIONS_PROCESS = frozenset({"opened", "synchronize", "reopened"}) | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class FilterResult: | ||
| """Result of event filter check.""" | ||
|
|
||
| should_process: bool | ||
| reason: str = "" | ||
|
|
||
|
|
||
| def should_process_event(event: WebhookEvent) -> FilterResult: | ||
| """ | ||
| Determine if an event should trigger rule evaluation. | ||
|
|
||
| Returns FilterResult with should_process=True to process, False to skip. | ||
| Logs filtered events for observability. | ||
| """ | ||
| payload = event.payload | ||
| event_type = event.event_type | ||
|
|
||
| result = _apply_filters(event_type, payload) | ||
| if not result.should_process: | ||
| logger.info( | ||
| "event_filtered", | ||
| event_type=event_type.value if hasattr(event_type, "value") else str(event_type), | ||
| repo=event.repo_full_name, | ||
| reason=result.reason, | ||
| ) | ||
| return result | ||
|
|
||
|
|
||
| def _apply_filters(event_type: EventType, payload: dict[str, Any]) -> FilterResult: | ||
| if _is_repo_archived(payload): | ||
| return FilterResult(should_process=False, reason="Repository is archived") | ||
|
|
||
| if event_type == EventType.PULL_REQUEST: | ||
| return _filter_pull_request(payload) | ||
| if event_type == EventType.PUSH: | ||
| return _filter_push(payload) | ||
| return FilterResult(should_process=True) | ||
|
|
||
|
|
||
| def _filter_pull_request(payload: dict[str, Any]) -> FilterResult: | ||
| action = payload.get("action") | ||
| if action not in PR_ACTIONS_PROCESS: | ||
| return FilterResult(should_process=False, reason=f"PR action '{action}' not processed") | ||
|
|
||
| pr = payload.get("pull_request", {}) | ||
| state = pr.get("state", "") | ||
| if state != "open": | ||
| return FilterResult(should_process=False, reason=f"PR state '{state}' not open") | ||
|
|
||
| if pr.get("merged"): | ||
| return FilterResult(should_process=False, reason="PR already merged") | ||
|
|
||
| if pr.get("draft"): | ||
| return FilterResult(should_process=False, reason="PR is draft") | ||
|
|
||
| return FilterResult(should_process=True) | ||
|
|
||
|
|
||
| def _filter_push(payload: dict[str, Any]) -> FilterResult: | ||
| if payload.get("deleted"): | ||
| return FilterResult(should_process=False, reason="Branch deletion event") | ||
|
|
||
| after = payload.get("after") | ||
| if not after or after == NULL_SHA: | ||
| return FilterResult(should_process=False, reason="No valid commit SHA (deleted or empty push)") | ||
|
|
||
| return FilterResult(should_process=True) | ||
|
|
||
|
|
||
| def _is_repo_archived(payload: dict[str, Any]) -> bool: | ||
| repo = payload.get("repository", {}) | ||
| return isinstance(repo, dict) and bool(repo.get("archived")) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ | |
|
|
||
| from src.agents import get_agent | ||
| from src.core.models import Severity, Violation | ||
| from src.core.utils.event_filter import NULL_SHA | ||
| from src.event_processors.base import BaseEventProcessor, ProcessingResult | ||
| from src.integrations.github.check_runs import CheckRunManager | ||
| from src.tasks.task_queue import Task | ||
|
|
@@ -37,6 +38,15 @@ async def process(self, task: Task) -> ProcessingResult: | |
| logger.info(f" Commits: {len(commits)}") | ||
| logger.info("=" * 80) | ||
|
|
||
| if payload.get("deleted") or not payload.get("after") or payload.get("after") == NULL_SHA: | ||
| logger.info("push_skipped_deleted_or_empty") | ||
| return ProcessingResult( | ||
| success=True, | ||
| violations=[], | ||
| api_calls_made=0, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. DRY / Constant: Please import |
||
| processing_time_ms=int((time.time() - start_time) * 1000), | ||
| ) | ||
|
Comment on lines
+41
to
+48
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unstructured log for the new skip path; missing required guideline fields
♻️ Add structured fields- logger.info("push_skipped_deleted_or_empty")
+ logger.info(
+ "push_skipped_deleted_or_empty",
+ operation="process_push",
+ subject_ids={"repo": task.repo_full_name, "ref": ref},
+ decision="skip",
+ latency_ms=int((time.time() - start_time) * 1000),
+ )🤖 Prompt for AI Agents |
||
|
|
||
| event_data = { | ||
| "push": { | ||
| "ref": ref, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,133 @@ | ||
| from src.core.models import EventType, WebhookEvent | ||
| from src.core.utils.event_filter import should_process_event | ||
|
|
||
|
|
||
| def _make_event(event_type: EventType, payload: dict) -> WebhookEvent: | ||
| return WebhookEvent(event_type=event_type, payload=payload) | ||
|
|
||
|
|
||
| def test_pull_request_opened_processes(): | ||
| payload = { | ||
| "action": "opened", | ||
| "repository": {"full_name": "owner/repo"}, | ||
| "pull_request": {"state": "open", "merged": False, "draft": False}, | ||
| } | ||
| result = should_process_event(_make_event(EventType.PULL_REQUEST, payload)) | ||
| assert result.should_process is True | ||
|
|
||
|
|
||
| def test_pull_request_synchronize_processes(): | ||
| payload = { | ||
| "action": "synchronize", | ||
| "repository": {"full_name": "owner/repo"}, | ||
| "pull_request": {"state": "open", "merged": False, "draft": False}, | ||
| } | ||
| result = should_process_event(_make_event(EventType.PULL_REQUEST, payload)) | ||
| assert result.should_process is True | ||
|
|
||
|
|
||
| def test_pull_request_reopened_processes(): | ||
| payload = { | ||
| "action": "reopened", | ||
| "repository": {"full_name": "owner/repo"}, | ||
| "pull_request": {"state": "open", "merged": False, "draft": False}, | ||
| } | ||
| result = should_process_event(_make_event(EventType.PULL_REQUEST, payload)) | ||
| assert result.should_process is True | ||
|
|
||
|
|
||
| def test_pull_request_closed_action_filtered(): | ||
| payload = { | ||
| "action": "closed", | ||
| "repository": {"full_name": "owner/repo"}, | ||
| "pull_request": {"state": "closed", "merged": True}, | ||
| } | ||
| result = should_process_event(_make_event(EventType.PULL_REQUEST, payload)) | ||
| assert result.should_process is False | ||
| assert "closed" in result.reason or "not processed" in result.reason | ||
|
|
||
|
|
||
| def test_pull_request_merged_filtered(): | ||
| payload = { | ||
| "action": "opened", | ||
| "repository": {"full_name": "owner/repo"}, | ||
| "pull_request": {"state": "closed", "merged": True, "draft": False}, | ||
| } | ||
| result = should_process_event(_make_event(EventType.PULL_REQUEST, payload)) | ||
| assert result.should_process is False | ||
| assert "merged" in result.reason or "not open" in result.reason | ||
|
|
||
|
|
||
| def test_pull_request_draft_filtered(): | ||
| payload = { | ||
| "action": "opened", | ||
| "repository": {"full_name": "owner/repo"}, | ||
| "pull_request": {"state": "open", "merged": False, "draft": True}, | ||
| } | ||
| result = should_process_event(_make_event(EventType.PULL_REQUEST, payload)) | ||
| assert result.should_process is False | ||
| assert "draft" in result.reason | ||
|
|
||
|
|
||
| def test_push_valid_processes(): | ||
| payload = { | ||
| "repository": {"full_name": "owner/repo"}, | ||
| "ref": "refs/heads/main", | ||
| "deleted": False, | ||
| "after": "abc123", | ||
| "commits": [{}], | ||
| } | ||
| result = should_process_event(_make_event(EventType.PUSH, payload)) | ||
| assert result.should_process is True | ||
|
|
||
|
|
||
| def test_push_deleted_branch_filtered(): | ||
| payload = { | ||
| "repository": {"full_name": "owner/repo"}, | ||
| "ref": "refs/heads/feature", | ||
| "deleted": True, | ||
| "after": "0000000000000000000000000000000000000000", | ||
| } | ||
| result = should_process_event(_make_event(EventType.PUSH, payload)) | ||
| assert result.should_process is False | ||
| assert "deletion" in result.reason or "Branch" in result.reason | ||
|
|
||
|
|
||
| def test_push_null_sha_filtered(): | ||
| payload = { | ||
| "repository": {"full_name": "owner/repo"}, | ||
| "ref": "refs/heads/main", | ||
| "deleted": False, | ||
| "after": "0000000000000000000000000000000000000000", | ||
| } | ||
| result = should_process_event(_make_event(EventType.PUSH, payload)) | ||
| assert result.should_process is False | ||
|
|
||
|
|
||
| def test_push_empty_after_filtered(): | ||
| payload = { | ||
| "repository": {"full_name": "owner/repo"}, | ||
| "ref": "refs/heads/main", | ||
| "deleted": False, | ||
| "after": "", | ||
| } | ||
| result = should_process_event(_make_event(EventType.PUSH, payload)) | ||
| assert result.should_process is False | ||
|
|
||
|
|
||
| def test_archived_repo_filtered(): | ||
| payload = { | ||
| "repository": {"full_name": "owner/repo", "archived": True}, | ||
| "action": "opened", | ||
| "pull_request": {"state": "open", "merged": False, "draft": False}, | ||
| } | ||
| result = should_process_event(_make_event(EventType.PULL_REQUEST, payload)) | ||
| assert result.should_process is False | ||
| assert "archived" in result.reason | ||
|
|
||
|
|
||
| def test_other_event_types_process(): | ||
| payload = {"repository": {"full_name": "owner/repo"}} | ||
| for evt in (EventType.CHECK_RUN, EventType.DEPLOYMENT, EventType.DEPLOYMENT_STATUS): | ||
| result = should_process_event(_make_event(evt, payload)) | ||
| assert result.should_process is True |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Structured log at filter boundary is missing required fields per guidelines
The coding guidelines require structured log entries at boundaries to carry
operation,subject_ids,decision, andlatency_ms. The current log omits all four.♻️ Proposed fix
🤖 Prompt for AI Agents