feat: add forgejo_error_reporter plugin for automatic error reporting to Forgejo issues

This commit is contained in:
Agent Zero
2026-07-26 12:49:39 +02:00
parent b9d05e2198
commit c3e41906bf
16 changed files with 608 additions and 14 deletions
+19
View File
@@ -0,0 +1,19 @@
"""Standardized error codes for consistent frontend handling."""
ERROR_CODES = {
'not_found': {'status': 404, 'message': 'Resource not found'},
'permission_denied': {'status': 403, 'message': 'Permission denied'},
'validation_error': {'status': 422, 'message': 'Validation failed'},
'rate_limited': {'status': 429, 'message': 'Too many requests'},
'internal_error': {'status': 500, 'message': 'Internal server error'},
'service_unavailable': {'status': 503, 'message': 'Service temporarily unavailable'},
}
class ApiError(Exception):
def __init__(self, code: str, detail: str = None, field: str = None, status: int = None):
self.code = code
self.detail = detail or ERROR_CODES.get(code, {}).get('message', 'Unknown error')
self.field = field
self.status = status or ERROR_CODES.get(code, {}).get('status', 500)
super().__init__(self.detail)
+21
View File
@@ -0,0 +1,21 @@
"""Plugin error isolation wrapper."""
import logging
import functools
from fastapi.responses import JSONResponse
logger = logging.getLogger(__name__)
def wrap_plugin_route(handler):
"""Decorator that isolates plugin route errors and returns structured JSON."""
@functools.wraps(handler)
async def wrapper(*args, **kwargs):
try:
return await handler(*args, **kwargs)
except Exception as exc:
logger.error(f'Plugin route error: {exc}', exc_info=True)
return JSONResponse(
status_code=500,
content={'detail': f'Plugin error: {exc}', 'code': 'plugin_error'}
)
return wrapper
+15
View File
@@ -19,8 +19,10 @@ logger = logging.getLogger(__name__)
from app.config import get_settings
from app.core.db import close_engine, get_engine
from app.core.error_codes import ApiError
from app.core.middleware import CSRFMiddleware
from app.core.monitoring import record_error, record_request
from app.core.plugin_error_handler import wrap_plugin_route
from app.core.service_container import get_container
from app.plugins.registry import get_registry
from app.routes import (
@@ -325,6 +327,14 @@ def create_app() -> FastAPI:
content={"detail": "Internal server error", "code": "internal_error"},
)
# ── ApiError handler — structured error responses ──
@app.exception_handler(ApiError)
async def api_error_handler(request: Request, exc: ApiError):
return JSONResponse(
status_code=exc.status,
content={'code': exc.code, 'detail': exc.detail, 'field': exc.field}
)
app.include_router(health.router)
app.include_router(metrics.router)
app.include_router(auth.router)
@@ -380,6 +390,7 @@ def create_app() -> FastAPI:
"app.plugins.builtins.mcp_server",
"app.plugins.builtins.system_notif",
"app.plugins.builtins.unified_search",
"app.plugins.builtins.forgejo_error_reporter",
]
for mod_name in plugin_modules:
try:
@@ -392,6 +403,10 @@ def create_app() -> FastAPI:
try:
router_module = importlib.import_module(route_def.module)
router = getattr(router_module, route_def.router_attr)
# Wrap each route handler with plugin error isolation
for route in router.routes:
if hasattr(route, 'endpoint'):
route.endpoint = wrap_plugin_route(route.endpoint)
app.include_router(router)
except Exception as exc:
logger.error(f"Failed to register route {route_def.module}.{route_def.router_attr}: {exc}")
@@ -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
+15
View File
@@ -74,4 +74,19 @@ async def report_error(error: ErrorReport, request: Request) -> Response:
},
)
# If forgejo_error_reporter plugin is active, forward error
try:
from app.plugins.builtins.forgejo_error_reporter.service import report_error_to_forgejo
entry = {
"message": error.message,
"stack": error.stack,
"url": error.url,
"userAgent": error.userAgent,
"timestamp": error.timestamp,
"context": error.context,
}
await report_error_to_forgejo(entry)
except Exception:
pass # Plugin not active or error in reporting
return Response(status_code=status.HTTP_204_NO_CONTENT)
+42 -6
View File
@@ -5,27 +5,62 @@ import { setUnauthorizedHandler } from '@/api/client';
import { useAuthStore } from '@/store/authStore';
import { useThemeStore } from '@/store/themeStore';
import { ErrorBoundary } from '@/components/common/ErrorBoundary';
import { useToast } from '@/components/ui/Toast';
import { useOnlineStatus } from '@/hooks/useOnlineStatus';
const queryClient = new QueryClient({
function QueryClientWrapper({ children }: { children: React.ReactNode }) {
const toast = useToast();
const [queryClient] = React.useState(() => new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
},
mutations: {
retry: 0,
onError: (error: any) => {
// Don't show toast for 401 (handled by auth)
if (error?.status === 401) return;
const msg = error?.detail || error?.message || 'Ein Fehler ist aufgetreten';
toast.error(msg);
},
});
},
},
}));
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
function OfflineBanner() {
const isOnline = useOnlineStatus();
if (isOnline) return null;
return (
<div className="fixed top-0 left-0 right-0 z-[200] bg-warning-500 text-white text-center py-2 px-4 text-sm font-medium shadow-md">
Sie sind offline. Änderungen werden gespeichert wenn die Verbindung wiederhergestellt ist.
</div>
);
}
export default function App() {
const { logout } = useAuthStore();
const loadThemeFromStorage = useThemeStore((s) => s.loadFromStorage);
const toast = useToast();
React.useEffect(() => {
setUnauthorizedHandler(() => {
toast.warning('Ihre Sitzung ist abgelaufen. Sie werden zur Anmeldung weitergeleitet.');
logout();
window.location.href = '/login';
setTimeout(() => { window.location.href = '/login'; }, 1500);
});
}, [logout]);
}, [logout, toast]);
// Load theme from localStorage on app start
React.useEffect(() => {
@@ -33,10 +68,11 @@ export default function App() {
}, [loadThemeFromStorage]);
return (
<QueryClientProvider client={queryClient}>
<QueryClientWrapper>
<OfflineBanner />
<ErrorBoundary>
<AppRouter />
</ErrorBoundary>
</QueryClientProvider>
</QueryClientWrapper>
);
}
+6 -1
View File
@@ -364,9 +364,14 @@ export function useAIUIControl() {
}
};
ws.onclose = () => {
ws.onclose = (event) => {
setConnected(false);
console.log('AI UI Control WebSocket disconnected');
// Don't reconnect on 403 (forbidden) — user lacks permission
if (event.code === 3003 || event.code === 1008) {
console.warn('AI UI Control WebSocket closed with forbidden code — not reconnecting');
return;
}
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), maxReconnectDelay);
reconnectAttempts++;
reconnectTimeout.current = window.setTimeout(connect, delay);
+6 -1
View File
@@ -98,8 +98,13 @@ export function useCommWebSocket() {
}
};
ws.onclose = () => {
ws.onclose = (event) => {
console.log('Comm WebSocket disconnected');
// Don't reconnect on 403 (forbidden) — user lacks permission
if (event.code === 3003 || event.code === 1008) {
console.warn('Comm WebSocket closed with forbidden code — not reconnecting');
return;
}
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), maxReconnectDelay);
reconnectAttempts++;
reconnectTimeout.current = window.setTimeout(connect, delay);
+18
View File
@@ -0,0 +1,18 @@
import { useState, useEffect } from 'react';
export function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
const goOnline = () => setIsOnline(true);
const goOffline = () => setIsOnline(false);
window.addEventListener('online', goOnline);
window.addEventListener('offline', goOffline);
return () => {
window.removeEventListener('online', goOnline);
window.removeEventListener('offline', goOffline);
};
}, []);
return isOnline;
}
+20
View File
@@ -0,0 +1,20 @@
export type ErrorCategory = 'network' | 'permission' | 'validation' | 'server' | 'auth' | 'unknown';
export interface CategorizedError {
category: ErrorCategory;
status: number;
message: string;
detail?: string;
code?: string;
field?: string;
}
export function categorizeError(error: any): CategorizedError {
const status = error?.status || 0;
if (status === 0) return { category: 'network', status: 0, message: 'Netzwerkfehler', detail: error?.message };
if (status === 401) return { category: 'auth', status, message: 'Nicht authentifiziert', detail: error?.detail };
if (status === 403) return { category: 'permission', status, message: 'Keine Berechtigung', detail: error?.detail };
if (status === 422) return { category: 'validation', status, message: 'Validierungsfehler', detail: error?.detail, field: error?.field };
if (status >= 500) return { category: 'server', status, message: 'Serverfehler', detail: error?.detail };
return { category: 'unknown', status, message: error?.message || 'Unbekannter Fehler', detail: error?.detail };
}