just why not
All checks were successful
AI Codebase Quality Review / ai-codebase-review (push) Successful in 39s
All checks were successful
AI Codebase Quality Review / ai-codebase-review (push) Successful in 39s
This commit is contained in:
542
tools/ai-review/notifications/notifier.py
Normal file
542
tools/ai-review/notifications/notifier.py
Normal file
@@ -0,0 +1,542 @@
|
||||
"""Notification System
|
||||
|
||||
Provides webhook-based notifications for Slack, Discord, and other platforms.
|
||||
Supports critical security findings, review summaries, and custom alerts.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class NotificationLevel(Enum):
|
||||
"""Notification severity levels."""
|
||||
|
||||
INFO = "info"
|
||||
WARNING = "warning"
|
||||
ERROR = "error"
|
||||
CRITICAL = "critical"
|
||||
|
||||
|
||||
@dataclass
|
||||
class NotificationMessage:
|
||||
"""A notification message."""
|
||||
|
||||
title: str
|
||||
message: str
|
||||
level: NotificationLevel = NotificationLevel.INFO
|
||||
fields: dict[str, str] | None = None
|
||||
url: str | None = None
|
||||
footer: str | None = None
|
||||
|
||||
|
||||
class Notifier(ABC):
|
||||
"""Abstract base class for notification providers."""
|
||||
|
||||
@abstractmethod
|
||||
def send(self, message: NotificationMessage) -> bool:
|
||||
"""Send a notification.
|
||||
|
||||
Args:
|
||||
message: The notification message to send.
|
||||
|
||||
Returns:
|
||||
True if sent successfully, False otherwise.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def send_raw(self, payload: dict) -> bool:
|
||||
"""Send a raw payload to the webhook.
|
||||
|
||||
Args:
|
||||
payload: Raw payload in provider-specific format.
|
||||
|
||||
Returns:
|
||||
True if sent successfully, False otherwise.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class SlackNotifier(Notifier):
|
||||
"""Slack webhook notifier."""
|
||||
|
||||
# Color mapping for different levels
|
||||
LEVEL_COLORS = {
|
||||
NotificationLevel.INFO: "#36a64f", # Green
|
||||
NotificationLevel.WARNING: "#ffcc00", # Yellow
|
||||
NotificationLevel.ERROR: "#ff6600", # Orange
|
||||
NotificationLevel.CRITICAL: "#cc0000", # Red
|
||||
}
|
||||
|
||||
LEVEL_EMOJIS = {
|
||||
NotificationLevel.INFO: ":information_source:",
|
||||
NotificationLevel.WARNING: ":warning:",
|
||||
NotificationLevel.ERROR: ":x:",
|
||||
NotificationLevel.CRITICAL: ":rotating_light:",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
webhook_url: str | None = None,
|
||||
channel: str | None = None,
|
||||
username: str = "OpenRabbit",
|
||||
icon_emoji: str = ":robot_face:",
|
||||
timeout: int = 10,
|
||||
):
|
||||
"""Initialize Slack notifier.
|
||||
|
||||
Args:
|
||||
webhook_url: Slack incoming webhook URL.
|
||||
Defaults to SLACK_WEBHOOK_URL env var.
|
||||
channel: Override channel (optional).
|
||||
username: Bot username to display.
|
||||
icon_emoji: Bot icon emoji.
|
||||
timeout: Request timeout in seconds.
|
||||
"""
|
||||
self.webhook_url = webhook_url or os.environ.get("SLACK_WEBHOOK_URL", "")
|
||||
self.channel = channel
|
||||
self.username = username
|
||||
self.icon_emoji = icon_emoji
|
||||
self.timeout = timeout
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def send(self, message: NotificationMessage) -> bool:
|
||||
"""Send a notification to Slack."""
|
||||
if not self.webhook_url:
|
||||
self.logger.warning("Slack webhook URL not configured")
|
||||
return False
|
||||
|
||||
# Build attachment
|
||||
attachment = {
|
||||
"color": self.LEVEL_COLORS.get(message.level, "#36a64f"),
|
||||
"title": f"{self.LEVEL_EMOJIS.get(message.level, '')} {message.title}",
|
||||
"text": message.message,
|
||||
"mrkdwn_in": ["text", "fields"],
|
||||
}
|
||||
|
||||
if message.url:
|
||||
attachment["title_link"] = message.url
|
||||
|
||||
if message.fields:
|
||||
attachment["fields"] = [
|
||||
{"title": k, "value": v, "short": len(v) < 40}
|
||||
for k, v in message.fields.items()
|
||||
]
|
||||
|
||||
if message.footer:
|
||||
attachment["footer"] = message.footer
|
||||
attachment["footer_icon"] = "https://github.com/favicon.ico"
|
||||
|
||||
payload = {
|
||||
"username": self.username,
|
||||
"icon_emoji": self.icon_emoji,
|
||||
"attachments": [attachment],
|
||||
}
|
||||
|
||||
if self.channel:
|
||||
payload["channel"] = self.channel
|
||||
|
||||
return self.send_raw(payload)
|
||||
|
||||
def send_raw(self, payload: dict) -> bool:
|
||||
"""Send raw payload to Slack webhook."""
|
||||
if not self.webhook_url:
|
||||
return False
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
self.webhook_url,
|
||||
json=payload,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return True
|
||||
except requests.RequestException as e:
|
||||
self.logger.error(f"Failed to send Slack notification: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class DiscordNotifier(Notifier):
|
||||
"""Discord webhook notifier."""
|
||||
|
||||
# Color mapping for different levels (Discord uses decimal colors)
|
||||
LEVEL_COLORS = {
|
||||
NotificationLevel.INFO: 3066993, # Green
|
||||
NotificationLevel.WARNING: 16776960, # Yellow
|
||||
NotificationLevel.ERROR: 16744448, # Orange
|
||||
NotificationLevel.CRITICAL: 13369344, # Red
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
webhook_url: str | None = None,
|
||||
username: str = "OpenRabbit",
|
||||
avatar_url: str | None = None,
|
||||
timeout: int = 10,
|
||||
):
|
||||
"""Initialize Discord notifier.
|
||||
|
||||
Args:
|
||||
webhook_url: Discord webhook URL.
|
||||
Defaults to DISCORD_WEBHOOK_URL env var.
|
||||
username: Bot username to display.
|
||||
avatar_url: Bot avatar URL.
|
||||
timeout: Request timeout in seconds.
|
||||
"""
|
||||
self.webhook_url = webhook_url or os.environ.get("DISCORD_WEBHOOK_URL", "")
|
||||
self.username = username
|
||||
self.avatar_url = avatar_url
|
||||
self.timeout = timeout
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def send(self, message: NotificationMessage) -> bool:
|
||||
"""Send a notification to Discord."""
|
||||
if not self.webhook_url:
|
||||
self.logger.warning("Discord webhook URL not configured")
|
||||
return False
|
||||
|
||||
# Build embed
|
||||
embed = {
|
||||
"title": message.title,
|
||||
"description": message.message,
|
||||
"color": self.LEVEL_COLORS.get(message.level, 3066993),
|
||||
}
|
||||
|
||||
if message.url:
|
||||
embed["url"] = message.url
|
||||
|
||||
if message.fields:
|
||||
embed["fields"] = [
|
||||
{"name": k, "value": v, "inline": len(v) < 40}
|
||||
for k, v in message.fields.items()
|
||||
]
|
||||
|
||||
if message.footer:
|
||||
embed["footer"] = {"text": message.footer}
|
||||
|
||||
payload = {
|
||||
"username": self.username,
|
||||
"embeds": [embed],
|
||||
}
|
||||
|
||||
if self.avatar_url:
|
||||
payload["avatar_url"] = self.avatar_url
|
||||
|
||||
return self.send_raw(payload)
|
||||
|
||||
def send_raw(self, payload: dict) -> bool:
|
||||
"""Send raw payload to Discord webhook."""
|
||||
if not self.webhook_url:
|
||||
return False
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
self.webhook_url,
|
||||
json=payload,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return True
|
||||
except requests.RequestException as e:
|
||||
self.logger.error(f"Failed to send Discord notification: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class WebhookNotifier(Notifier):
|
||||
"""Generic webhook notifier for custom integrations."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
webhook_url: str,
|
||||
headers: dict[str, str] | None = None,
|
||||
timeout: int = 10,
|
||||
):
|
||||
"""Initialize generic webhook notifier.
|
||||
|
||||
Args:
|
||||
webhook_url: Webhook URL.
|
||||
headers: Custom headers to include.
|
||||
timeout: Request timeout in seconds.
|
||||
"""
|
||||
self.webhook_url = webhook_url
|
||||
self.headers = headers or {"Content-Type": "application/json"}
|
||||
self.timeout = timeout
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def send(self, message: NotificationMessage) -> bool:
|
||||
"""Send a notification as JSON payload."""
|
||||
payload = {
|
||||
"title": message.title,
|
||||
"message": message.message,
|
||||
"level": message.level.value,
|
||||
"fields": message.fields or {},
|
||||
"url": message.url,
|
||||
"footer": message.footer,
|
||||
}
|
||||
return self.send_raw(payload)
|
||||
|
||||
def send_raw(self, payload: dict) -> bool:
|
||||
"""Send raw payload to webhook."""
|
||||
try:
|
||||
response = requests.post(
|
||||
self.webhook_url,
|
||||
json=payload,
|
||||
headers=self.headers,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return True
|
||||
except requests.RequestException as e:
|
||||
self.logger.error(f"Failed to send webhook notification: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class NotifierFactory:
|
||||
"""Factory for creating notifier instances from config."""
|
||||
|
||||
@staticmethod
|
||||
def create_from_config(config: dict) -> list[Notifier]:
|
||||
"""Create notifier instances from configuration.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary with 'notifications' section.
|
||||
|
||||
Returns:
|
||||
List of configured notifier instances.
|
||||
"""
|
||||
notifiers = []
|
||||
notifications_config = config.get("notifications", {})
|
||||
|
||||
if not notifications_config.get("enabled", False):
|
||||
return notifiers
|
||||
|
||||
# Slack
|
||||
slack_config = notifications_config.get("slack", {})
|
||||
if slack_config.get("enabled", False):
|
||||
webhook_url = slack_config.get("webhook_url") or os.environ.get(
|
||||
"SLACK_WEBHOOK_URL"
|
||||
)
|
||||
if webhook_url:
|
||||
notifiers.append(
|
||||
SlackNotifier(
|
||||
webhook_url=webhook_url,
|
||||
channel=slack_config.get("channel"),
|
||||
username=slack_config.get("username", "OpenRabbit"),
|
||||
)
|
||||
)
|
||||
|
||||
# Discord
|
||||
discord_config = notifications_config.get("discord", {})
|
||||
if discord_config.get("enabled", False):
|
||||
webhook_url = discord_config.get("webhook_url") or os.environ.get(
|
||||
"DISCORD_WEBHOOK_URL"
|
||||
)
|
||||
if webhook_url:
|
||||
notifiers.append(
|
||||
DiscordNotifier(
|
||||
webhook_url=webhook_url,
|
||||
username=discord_config.get("username", "OpenRabbit"),
|
||||
avatar_url=discord_config.get("avatar_url"),
|
||||
)
|
||||
)
|
||||
|
||||
# Generic webhooks
|
||||
webhooks_config = notifications_config.get("webhooks", [])
|
||||
for webhook in webhooks_config:
|
||||
if webhook.get("enabled", True) and webhook.get("url"):
|
||||
notifiers.append(
|
||||
WebhookNotifier(
|
||||
webhook_url=webhook["url"],
|
||||
headers=webhook.get("headers"),
|
||||
)
|
||||
)
|
||||
|
||||
return notifiers
|
||||
|
||||
@staticmethod
|
||||
def should_notify(
|
||||
level: NotificationLevel,
|
||||
config: dict,
|
||||
) -> bool:
|
||||
"""Check if notification should be sent based on level threshold.
|
||||
|
||||
Args:
|
||||
level: Notification level.
|
||||
config: Configuration dictionary.
|
||||
|
||||
Returns:
|
||||
True if notification should be sent.
|
||||
"""
|
||||
notifications_config = config.get("notifications", {})
|
||||
if not notifications_config.get("enabled", False):
|
||||
return False
|
||||
|
||||
threshold = notifications_config.get("threshold", "warning")
|
||||
level_order = ["info", "warning", "error", "critical"]
|
||||
|
||||
try:
|
||||
threshold_idx = level_order.index(threshold)
|
||||
level_idx = level_order.index(level.value)
|
||||
return level_idx >= threshold_idx
|
||||
except ValueError:
|
||||
return True
|
||||
|
||||
|
||||
class NotificationService:
|
||||
"""High-level notification service for sending alerts."""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
"""Initialize notification service.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary.
|
||||
"""
|
||||
self.config = config
|
||||
self.notifiers = NotifierFactory.create_from_config(config)
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def notify(self, message: NotificationMessage) -> bool:
|
||||
"""Send notification to all configured notifiers.
|
||||
|
||||
Args:
|
||||
message: Notification message.
|
||||
|
||||
Returns:
|
||||
True if at least one notification succeeded.
|
||||
"""
|
||||
if not NotifierFactory.should_notify(message.level, self.config):
|
||||
return True # Not an error, just below threshold
|
||||
|
||||
if not self.notifiers:
|
||||
self.logger.debug("No notifiers configured")
|
||||
return True
|
||||
|
||||
success = False
|
||||
for notifier in self.notifiers:
|
||||
try:
|
||||
if notifier.send(message):
|
||||
success = True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Notifier failed: {e}")
|
||||
|
||||
return success
|
||||
|
||||
def notify_security_finding(
|
||||
self,
|
||||
repo: str,
|
||||
pr_number: int,
|
||||
finding: dict,
|
||||
pr_url: str | None = None,
|
||||
) -> bool:
|
||||
"""Send notification for a security finding.
|
||||
|
||||
Args:
|
||||
repo: Repository name (owner/repo).
|
||||
pr_number: Pull request number.
|
||||
finding: Security finding dict with severity, description, etc.
|
||||
pr_url: URL to the pull request.
|
||||
|
||||
Returns:
|
||||
True if notification succeeded.
|
||||
"""
|
||||
severity = finding.get("severity", "MEDIUM").upper()
|
||||
level_map = {
|
||||
"HIGH": NotificationLevel.CRITICAL,
|
||||
"MEDIUM": NotificationLevel.WARNING,
|
||||
"LOW": NotificationLevel.INFO,
|
||||
}
|
||||
level = level_map.get(severity, NotificationLevel.WARNING)
|
||||
|
||||
message = NotificationMessage(
|
||||
title=f"Security Finding in {repo} PR #{pr_number}",
|
||||
message=finding.get("description", "Security issue detected"),
|
||||
level=level,
|
||||
fields={
|
||||
"Severity": severity,
|
||||
"Category": finding.get("category", "Unknown"),
|
||||
"File": finding.get("file", "N/A"),
|
||||
"Line": str(finding.get("line", "N/A")),
|
||||
},
|
||||
url=pr_url,
|
||||
footer=f"CWE: {finding.get('cwe', 'N/A')}",
|
||||
)
|
||||
|
||||
return self.notify(message)
|
||||
|
||||
def notify_review_complete(
|
||||
self,
|
||||
repo: str,
|
||||
pr_number: int,
|
||||
summary: dict,
|
||||
pr_url: str | None = None,
|
||||
) -> bool:
|
||||
"""Send notification for completed review.
|
||||
|
||||
Args:
|
||||
repo: Repository name (owner/repo).
|
||||
pr_number: Pull request number.
|
||||
summary: Review summary dict.
|
||||
pr_url: URL to the pull request.
|
||||
|
||||
Returns:
|
||||
True if notification succeeded.
|
||||
"""
|
||||
recommendation = summary.get("recommendation", "COMMENT")
|
||||
level_map = {
|
||||
"APPROVE": NotificationLevel.INFO,
|
||||
"COMMENT": NotificationLevel.INFO,
|
||||
"REQUEST_CHANGES": NotificationLevel.WARNING,
|
||||
}
|
||||
level = level_map.get(recommendation, NotificationLevel.INFO)
|
||||
|
||||
high_issues = summary.get("high_severity_count", 0)
|
||||
if high_issues > 0:
|
||||
level = NotificationLevel.ERROR
|
||||
|
||||
message = NotificationMessage(
|
||||
title=f"Review Complete: {repo} PR #{pr_number}",
|
||||
message=summary.get("summary", "AI review completed"),
|
||||
level=level,
|
||||
fields={
|
||||
"Recommendation": recommendation,
|
||||
"High Severity": str(high_issues),
|
||||
"Medium Severity": str(summary.get("medium_severity_count", 0)),
|
||||
"Files Reviewed": str(summary.get("files_reviewed", 0)),
|
||||
},
|
||||
url=pr_url,
|
||||
footer="OpenRabbit AI Review",
|
||||
)
|
||||
|
||||
return self.notify(message)
|
||||
|
||||
def notify_error(
|
||||
self,
|
||||
repo: str,
|
||||
error: str,
|
||||
context: str | None = None,
|
||||
) -> bool:
|
||||
"""Send notification for an error.
|
||||
|
||||
Args:
|
||||
repo: Repository name.
|
||||
error: Error message.
|
||||
context: Additional context.
|
||||
|
||||
Returns:
|
||||
True if notification succeeded.
|
||||
"""
|
||||
message = NotificationMessage(
|
||||
title=f"Error in {repo}",
|
||||
message=error,
|
||||
level=NotificationLevel.ERROR,
|
||||
fields={"Context": context} if context else None,
|
||||
footer="OpenRabbit Error Alert",
|
||||
)
|
||||
|
||||
return self.notify(message)
|
||||
Reference in New Issue
Block a user