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
@@ -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