feat: add forgejo_error_reporter plugin for automatic error reporting to Forgejo issues
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
"""Forgejo Error Reporter plugin — automatically reports errors as Forgejo issues."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.plugins.builtins.forgejo_error_reporter.plugin import ForgejoErrorReporterPlugin
|
||||
|
||||
__all__ = ["ForgejoErrorReporterPlugin"]
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Migration 0001: Create forgejo_reported_errors table
|
||||
-- Tracks which errors have been reported to Forgejo for audit purposes.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS forgejo_reported_errors (
|
||||
id SERIAL PRIMARY KEY,
|
||||
dedup_key VARCHAR(64) NOT NULL UNIQUE,
|
||||
message TEXT NOT NULL,
|
||||
stack TEXT,
|
||||
forgejo_issue_number INTEGER,
|
||||
reported_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'reported'
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_forgejo_reported_errors_dedup_key ON forgejo_reported_errors(dedup_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_forgejo_reported_errors_reported_at ON forgejo_reported_errors(reported_at);
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Database models for the Forgejo Error Reporter plugin.
|
||||
|
||||
Tracks which errors have been reported to Forgejo for audit purposes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Column, DateTime, Integer, String, Text, func
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
class ReportedError(Base):
|
||||
"""Tracks errors that have been reported to Forgejo."""
|
||||
|
||||
__tablename__ = "forgejo_reported_errors"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
dedup_key = Column(String(64), nullable=False, unique=True, index=True, comment="SHA256 hash of message+stack for deduplication")
|
||||
message = Column(Text, nullable=False, comment="Error message")
|
||||
stack = Column(Text, nullable=True, comment="Stack trace")
|
||||
forgejo_issue_number = Column(Integer, nullable=True, comment="Forgejo issue number")
|
||||
reported_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, comment="When the error was reported")
|
||||
status = Column(String(20), nullable=False, default="reported", comment="reported/failed")
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Forgejo Error Reporter plugin — automatically reports errors as Forgejo issues.
|
||||
|
||||
Only active in test/staging environments. Disabled in production.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from app.plugins.base import BasePlugin
|
||||
from app.plugins.manifest import PluginManifest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ForgejoErrorReporterPlugin(BasePlugin):
|
||||
"""Plugin that forwards errors to Forgejo as issues."""
|
||||
|
||||
manifest = PluginManifest(
|
||||
name="forgejo_error_reporter",
|
||||
version="1.0.0",
|
||||
display_name="Forgejo Error Reporter",
|
||||
description="Automatically reports errors to Forgejo as issues. Test environment only.",
|
||||
is_core=False,
|
||||
dependencies=[],
|
||||
events=[],
|
||||
migrations=[],
|
||||
permissions=[],
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._enabled: bool = False
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""Whether the plugin is enabled based on environment and settings."""
|
||||
return self._enabled
|
||||
|
||||
@enabled.setter
|
||||
def enabled(self, value: bool) -> None:
|
||||
self._enabled = value
|
||||
|
||||
async def on_activate(self, db: Any, service_container: Any, event_bus: Any) -> None:
|
||||
"""Activate the plugin — check environment and enable if not production."""
|
||||
await super().on_activate(db, service_container, event_bus)
|
||||
|
||||
environment = os.environ.get("FORGEJO_ERROR_REPORTER_ENV", "test").lower()
|
||||
enabled_flag = os.environ.get("FORGEJO_ERROR_REPORTER_ENABLED", "false").lower() in ("true", "1", "yes")
|
||||
|
||||
if environment == "production":
|
||||
logger.info(
|
||||
"Forgejo Error Reporter is disabled in production environment. "
|
||||
"Set FORGEJO_ERROR_REPORTER_ENV to 'test' or 'staging' to enable."
|
||||
)
|
||||
self._enabled = False
|
||||
return
|
||||
|
||||
if not enabled_flag:
|
||||
logger.info(
|
||||
"Forgejo Error Reporter is disabled via FORGEJO_ERROR_REPORTER_ENABLED=false. "
|
||||
"Set it to 'true' to enable."
|
||||
)
|
||||
self._enabled = False
|
||||
return
|
||||
|
||||
# Check required settings
|
||||
token = os.environ.get("FORGEJO_ERROR_REPORTER_TOKEN", "")
|
||||
if not token:
|
||||
logger.warning(
|
||||
"Forgejo Error Reporter: FORGEJO_ERROR_REPORTER_TOKEN is not set. "
|
||||
"Plugin will be inactive until token is configured."
|
||||
)
|
||||
self._enabled = False
|
||||
return
|
||||
|
||||
self._enabled = True
|
||||
logger.info(
|
||||
"Forgejo Error Reporter enabled (env=%s, url=%s, owner=%s, repo=%s)",
|
||||
environment,
|
||||
os.environ.get("FORGEJO_ERROR_REPORTER_URL", "https://forgejo.media-on.de"),
|
||||
os.environ.get("FORGEJO_ERROR_REPORTER_OWNER", "Leopoldadmin"),
|
||||
os.environ.get("FORGEJO_ERROR_REPORTER_REPO", "leocrm"),
|
||||
)
|
||||
|
||||
async def on_deactivate(self, db: Any, service_container: Any, event_bus: Any) -> None:
|
||||
"""Deactivate the plugin."""
|
||||
self._enabled = False
|
||||
await super().on_deactivate(db, service_container, event_bus)
|
||||
logger.info("Forgejo Error Reporter deactivated")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""API routes for the Forgejo Error Reporter plugin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/forgejo-error-reporter", tags=["forgejo_error_reporter"])
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_status(request: Request) -> dict:
|
||||
"""Get the current status of the Forgejo Error Reporter plugin."""
|
||||
from app.plugins.registry import get_registry
|
||||
|
||||
registry = get_registry()
|
||||
plugin = registry.get_plugin("forgejo_error_reporter")
|
||||
|
||||
if plugin is None:
|
||||
return {"enabled": False, "reason": "Plugin not found"}
|
||||
|
||||
return {
|
||||
"enabled": plugin.enabled,
|
||||
"environment": plugin.enabled and "non-production" or "disabled",
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Pydantic schemas for the Forgejo Error Reporter plugin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ForgejoErrorReport(BaseModel):
|
||||
"""Schema for an error report that will be forwarded to Forgejo."""
|
||||
|
||||
message: str = Field(..., max_length=2000, description="Error message")
|
||||
stack: str | None = Field(None, max_length=10000, description="Stack trace")
|
||||
url: str | None = Field(None, max_length=500, description="URL where error occurred")
|
||||
user_agent: str | None = Field(None, max_length=500, description="User agent string")
|
||||
timestamp: str | None = Field(None, description="Error timestamp")
|
||||
context: dict[str, Any] | None = Field(None, description="Additional context")
|
||||
|
||||
|
||||
class ForgejoIssueResponse(BaseModel):
|
||||
"""Response from creating a Forgejo issue."""
|
||||
|
||||
success: bool = Field(..., description="Whether the issue was created successfully")
|
||||
issue_number: int | None = Field(None, description="Forgejo issue number if created")
|
||||
issue_url: str | None = Field(None, description="URL to the created issue")
|
||||
error: str | None = Field(None, description="Error message if creation failed")
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Forgejo API client for error reporting with deduplication and rate limiting."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Deduplication cache ──────────────────────────────────────────────────────
|
||||
# In-memory cache: key = hash(message + stack), value = timestamp
|
||||
# Max 100 entries, TTL 1 hour
|
||||
_DEDUP_CACHE: OrderedDict[str, float] = OrderedDict()
|
||||
_DEDUP_MAX_SIZE = 100
|
||||
_DEDUP_TTL_SECONDS = 3600 # 1 hour
|
||||
|
||||
# ── Rate limiting ────────────────────────────────────────────────────────────
|
||||
# Max 10 issues per hour
|
||||
_RATE_LIMIT_MAX = 10
|
||||
_RATE_LIMIT_WINDOW = 3600 # 1 hour in seconds
|
||||
_rate_limit_timestamps: list[float] = []
|
||||
|
||||
# ── Lock for thread safety ──────────────────────────────────────────────────
|
||||
_lock = asyncio.Lock()
|
||||
|
||||
|
||||
# ── Settings helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
def _get_settings() -> dict[str, str]:
|
||||
"""Get Forgejo settings from environment variables."""
|
||||
return {
|
||||
"url": os.environ.get("FORGEJO_ERROR_REPORTER_URL", "https://forgejo.media-on.de").rstrip("/"),
|
||||
"token": os.environ.get("FORGEJO_ERROR_REPORTER_TOKEN", ""),
|
||||
"owner": os.environ.get("FORGEJO_ERROR_REPORTER_OWNER", "Leopoldadmin"),
|
||||
"repo": os.environ.get("FORGEJO_ERROR_REPORTER_REPO", "leocrm"),
|
||||
}
|
||||
|
||||
|
||||
def _make_dedup_key(message: str, stack: str | None) -> str:
|
||||
"""Create a deduplication key from error message and stack trace."""
|
||||
import hashlib
|
||||
raw = f"{message}|||{stack or ''}"
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
async def _is_duplicate(dedup_key: str) -> bool:
|
||||
"""Check if an error with this key was already reported recently.
|
||||
|
||||
Cleans expired entries and enforces max cache size.
|
||||
"""
|
||||
async with _lock:
|
||||
now = time.monotonic()
|
||||
|
||||
# Clean expired entries
|
||||
expired_keys = [
|
||||
k for k, ts in _DEDUP_CACHE.items()
|
||||
if now - ts > _DEDUP_TTL_SECONDS
|
||||
]
|
||||
for k in expired_keys:
|
||||
del _DEDUP_CACHE[k]
|
||||
|
||||
# Check if key exists and is still valid
|
||||
if dedup_key in _DEDUP_CACHE:
|
||||
logger.debug("Duplicate error detected, skipping Forgejo issue creation")
|
||||
return True
|
||||
|
||||
# Enforce max cache size
|
||||
while len(_DEDUP_CACHE) >= _DEDUP_MAX_SIZE:
|
||||
_DEDUP_CACHE.popitem(last=False)
|
||||
|
||||
# Add new entry
|
||||
_DEDUP_CACHE[dedup_key] = now
|
||||
return False
|
||||
|
||||
|
||||
async def _check_rate_limit() -> bool:
|
||||
"""Check if we're within the rate limit.
|
||||
|
||||
Returns True if request is allowed, False if rate limited.
|
||||
"""
|
||||
async with _lock:
|
||||
now = time.monotonic()
|
||||
window_start = now - _RATE_LIMIT_WINDOW
|
||||
|
||||
# Remove timestamps outside the window
|
||||
while _rate_limit_timestamps and _rate_limit_timestamps[0] < window_start:
|
||||
_rate_limit_timestamps.pop(0)
|
||||
|
||||
if len(_rate_limit_timestamps) >= _RATE_LIMIT_MAX:
|
||||
logger.warning(
|
||||
"Forgejo Error Reporter rate limited: %d issues in the last hour",
|
||||
len(_rate_limit_timestamps),
|
||||
)
|
||||
return False
|
||||
|
||||
_rate_limit_timestamps.append(now)
|
||||
return True
|
||||
|
||||
|
||||
async def _ensure_labels_exist(client: httpx.AsyncClient, settings: dict[str, str]) -> None:
|
||||
"""Ensure required labels exist in the Forgejo repository.
|
||||
|
||||
Creates 'auto-reported' and 'bug' labels if they don't exist.
|
||||
"""
|
||||
url = f"{settings['url']}/api/v1/repos/{settings['owner']}/{settings['repo']}/labels"
|
||||
headers = {
|
||||
"Authorization": f"token {settings['token']}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
try:
|
||||
response = await client.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
existing_labels = response.json()
|
||||
existing_names = {label.get("name", "") for label in existing_labels}
|
||||
|
||||
required_labels = [
|
||||
{"name": "auto-reported", "color": "0366d6", "description": "Automatically reported by error reporter"},
|
||||
{"name": "bug", "color": "d73a4a", "description": "Bug report"},
|
||||
]
|
||||
|
||||
for label_data in required_labels:
|
||||
if label_data["name"] not in existing_names:
|
||||
create_response = await client.post(url, headers=headers, json=label_data)
|
||||
if create_response.status_code in (201, 200):
|
||||
logger.info("Created label '%s' in Forgejo repo", label_data["name"])
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to create label '%s': %s",
|
||||
label_data["name"],
|
||||
create_response.text,
|
||||
)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
logger.warning("Failed to fetch/create labels: %s", exc)
|
||||
except httpx.RequestError as exc:
|
||||
logger.warning("Network error while managing labels: %s", exc)
|
||||
|
||||
|
||||
async def report_error_to_forgejo(entry: dict[str, Any]) -> bool:
|
||||
"""Report an error to Forgejo as a new issue.
|
||||
|
||||
Args:
|
||||
entry: Error entry dict with keys:
|
||||
- message: Error message (required)
|
||||
- stack: Stack trace (optional)
|
||||
- url: URL where error occurred (optional)
|
||||
- userAgent: User agent string (optional)
|
||||
- timestamp: Error timestamp (optional)
|
||||
- context: Additional context dict (optional)
|
||||
|
||||
Returns:
|
||||
True if the issue was created successfully, False otherwise.
|
||||
"""
|
||||
settings = _get_settings()
|
||||
|
||||
# Validate settings
|
||||
if not settings["token"]:
|
||||
logger.debug("Forgejo token not configured, skipping error report")
|
||||
return False
|
||||
|
||||
message = entry.get("message", "Unknown error")
|
||||
stack = entry.get("stack")
|
||||
|
||||
# Deduplication check
|
||||
dedup_key = _make_dedup_key(message, stack)
|
||||
if await _is_duplicate(dedup_key):
|
||||
return False
|
||||
|
||||
# Rate limit check
|
||||
if not await _check_rate_limit():
|
||||
return False
|
||||
|
||||
# Build issue body
|
||||
body_parts = ["## Error Report\n"]
|
||||
body_parts.append(f"**Message:** {message}")
|
||||
if stack:
|
||||
body_parts.append(f"\n**Stack:**\n```\n{stack}\n```")
|
||||
if entry.get("url"):
|
||||
body_parts.append(f"\n**URL:** {entry['url']}")
|
||||
if entry.get("userAgent"):
|
||||
body_parts.append(f"\n**User Agent:** {entry['userAgent']}")
|
||||
if entry.get("timestamp"):
|
||||
body_parts.append(f"\n**Timestamp:** {entry['timestamp']}")
|
||||
if entry.get("context"):
|
||||
import json
|
||||
context_str = json.dumps(entry["context"], indent=2, default=str)
|
||||
body_parts.append(f"\n**Context:**\n```json\n{context_str}\n```")
|
||||
|
||||
body = "\n".join(body_parts)
|
||||
|
||||
# Truncate title if too long (Forgejo/Gitea has limits)
|
||||
title = f"[Auto] {message}"
|
||||
if len(title) > 255:
|
||||
title = title[:252] + "..."
|
||||
|
||||
issue_data = {
|
||||
"title": title,
|
||||
"body": body,
|
||||
"labels": ["auto-reported", "bug"],
|
||||
}
|
||||
|
||||
url = f"{settings['url']}/api/v1/repos/{settings['owner']}/{settings['repo']}/issues"
|
||||
headers = {
|
||||
"Authorization": f"token {settings['token']}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
# Ensure labels exist first
|
||||
await _ensure_labels_exist(client, settings)
|
||||
|
||||
try:
|
||||
response = await client.post(url, headers=headers, json=issue_data)
|
||||
if response.status_code in (201, 200):
|
||||
issue = response.json()
|
||||
issue_number = issue.get("number", "unknown")
|
||||
logger.info(
|
||||
"Created Forgejo issue #%s for error: %s",
|
||||
issue_number,
|
||||
message[:100],
|
||||
)
|
||||
return True
|
||||
else:
|
||||
logger.error(
|
||||
"Failed to create Forgejo issue (status=%d): %s",
|
||||
response.status_code,
|
||||
response.text[:500],
|
||||
)
|
||||
return False
|
||||
except httpx.HTTPStatusError as exc:
|
||||
logger.error("HTTP error creating Forgejo issue: %s", exc)
|
||||
return False
|
||||
except httpx.RequestError as exc:
|
||||
logger.error("Network error creating Forgejo issue: %s", exc)
|
||||
return False
|
||||
except Exception as exc:
|
||||
logger.error("Unexpected error creating Forgejo issue: %s", exc)
|
||||
return False
|
||||
Reference in New Issue
Block a user