"""Sentry → Gitea incident bridge (#607). Reads unresolved issues/events from a **self-hosted** Sentry, normalizes them into #612 observations, and reconciles them into durable Gitea issues. Hard rules (inherited from #612 and restated here): * Gitea owns workflow state; Sentry is observability **input only**. * Raw Sentry incidents are never assignable control-plane ``work_items``. * Dedupe/link/create is delegated to :mod:`incident_bridge` — this module never invents a second linking substrate. * Tokens, DSNs, and raw headers never appear in returns, bodies, or logs. * The watchdog defaults to dry-run; ``apply`` is explicit. Network access is injected as ``http_fn`` so the whole surface is testable without a live Sentry. """ from __future__ import annotations import dataclasses import json import os import re import urllib.error import urllib.parse import urllib.request from dataclasses import dataclass from typing import Any, Callable, Sequence import incident_bridge import sentry_observability PROVIDER = "sentry" ENV_BASE_URL = "SENTRY_BASE_URL" ENV_AUTH_TOKEN = "SENTRY_AUTH_TOKEN" ENV_ORG = "SENTRY_ORG" ENV_PROJECT = "SENTRY_PROJECT" ENV_ENVIRONMENT = "SENTRY_ENVIRONMENT" ENV_BRIDGE_ENABLED = "MCP_SENTRY_ISSUE_BRIDGE_ENABLED" ENV_MIN_EVENTS = "MCP_SENTRY_MIN_EVENTS_FOR_ISSUE" ENV_LOOKBACK = "MCP_SENTRY_LOOKBACK" DEFAULT_BASE_URL = "https://sentry.prgs.cc" DEFAULT_LOOKBACK = "24h" DEFAULT_MIN_EVENTS = 2 DEFAULT_TIMEOUT = 15.0 DEFAULT_PAGE_SIZE = 25 MAX_PAGE_SIZE = 100 DEFAULT_MAX_PAGES = 10 _TRUTHY = frozenset({"1", "true", "yes", "on"}) # Absolute local paths embedded in free text. Mirrors the shape matched by # sentry_observability's internal path detector; each hit is replaced by the # coarse category from sentry_observability.sanitize_path. _ABS_PATH_RE = re.compile( r"(?:/private)?/(?:Users|home|tmp|var|opt|Volumes)/[^\s\"']*" ) _LOOKBACK_RE = re.compile(r"^\d+[mhd]$") _CURSOR_RE = re.compile(r'cursor="([^"]+)"') _RESULTS_RE = re.compile(r'results="([^"]+)"') _REL_RE = re.compile(r'rel="([^"]+)"') # Error kinds surfaced to callers (stable strings; safe to branch on). ERROR_NOT_CONFIGURED = "not_configured" ERROR_MISSING_TOKEN = "missing_token" ERROR_UNAVAILABLE = "sentry_unavailable" ERROR_HTTP = "sentry_http_error" ERROR_INVALID_RESPONSE = "invalid_response" ERROR_BRIDGE_DISABLED = "bridge_disabled" # Watchdog per-issue dispositions. ACTION_RECONCILED = "reconciled" ACTION_SKIPPED_THRESHOLD = "skipped_below_event_threshold" ACTION_SKIPPED_STATUS = "skipped_not_unresolved" ACTION_FAILED = "failed" class SentryApiError(RuntimeError): """Sentry read failure with a stable, redacted classification.""" def __init__(self, message: str, *, kind: str, status: int | None = None): super().__init__(incident_bridge.redact_text(message)) self.kind = kind self.status = status def as_dict(self) -> dict[str, Any]: return { "error_kind": self.kind, "status": self.status, "message": str(self), } @dataclass(frozen=True) class SentryBridgeConfig: """Resolved bridge configuration. Never carries the auth token.""" base_url: str org: str project: str environment: str | None = None lookback: str = DEFAULT_LOOKBACK min_events_for_issue: int = DEFAULT_MIN_EVENTS bridge_enabled: bool = False timeout: float = DEFAULT_TIMEOUT def issues_path(self) -> str: return f"/api/0/projects/{self.org}/{self.project}/issues/" def issue_events_path(self, issue_id: str) -> str: return f"/api/0/issues/{issue_id}/events/" def as_dict(self) -> dict[str, Any]: """Safe projection. The auth token is never included by construction.""" return { "base_url": self.base_url, "org": self.org, "project": self.project, "environment": self.environment, "lookback": self.lookback, "min_events_for_issue": self.min_events_for_issue, "bridge_enabled": self.bridge_enabled, "self_hosted": not self.base_url.rstrip("/").endswith("sentry.io"), } def _env_bool(name: str, env: dict[str, str], default: bool = False) -> bool: raw = (env.get(name) or "").strip().lower() if not raw: return default return raw in _TRUTHY def _env_int(name: str, env: dict[str, str], default: int) -> int: raw = (env.get(name) or "").strip() if not raw: return default try: value = int(raw) except ValueError: return default return value if value >= 1 else default def load_bridge_config(env: dict[str, str] | None = None) -> SentryBridgeConfig: """Build config from environment. Never reads or returns the token value.""" source = dict(env if env is not None else os.environ) base_url = (source.get(ENV_BASE_URL) or DEFAULT_BASE_URL).strip().rstrip("/") lookback = (source.get(ENV_LOOKBACK) or DEFAULT_LOOKBACK).strip() if not _LOOKBACK_RE.match(lookback): lookback = DEFAULT_LOOKBACK environment = (source.get(ENV_ENVIRONMENT) or "").strip() or None return SentryBridgeConfig( base_url=base_url, org=(source.get(ENV_ORG) or "").strip(), project=(source.get(ENV_PROJECT) or "").strip(), environment=environment, lookback=lookback, min_events_for_issue=_env_int(ENV_MIN_EVENTS, source, DEFAULT_MIN_EVENTS), bridge_enabled=_env_bool(ENV_BRIDGE_ENABLED, source, False), ) def config_with_overrides( config: SentryBridgeConfig, *, base_url: str | None = None, org: str | None = None, project: str | None = None, lookback: str | None = None, min_events_for_issue: int | None = None, ) -> SentryBridgeConfig: """Return *config* with explicit per-call overrides applied.""" overrides: dict[str, Any] = {} if base_url: overrides["base_url"] = str(base_url).strip().rstrip("/") if org: overrides["org"] = str(org).strip() if project: overrides["project"] = str(project).strip() if lookback: candidate = str(lookback).strip() overrides["lookback"] = candidate if _LOOKBACK_RE.match(candidate) else config.lookback if min_events_for_issue is not None: overrides["min_events_for_issue"] = max(1, int(min_events_for_issue)) return dataclasses.replace(config, **overrides) if overrides else config def resolve_token(env: dict[str, str] | None = None) -> str: """Return the Sentry auth token from env only (never logged or returned).""" source = env if env is not None else os.environ return (source.get(ENV_AUTH_TOKEN) or "").strip() def assert_configured(config: SentryBridgeConfig, token: str) -> None: """Fail closed before any network call.""" missing = [ name for name, value in ( (ENV_BASE_URL, config.base_url), (ENV_ORG, config.org), (ENV_PROJECT, config.project), ) if not value ] if missing: raise SentryApiError( "Sentry bridge is not configured; missing " + ", ".join(sorted(missing)), kind=ERROR_NOT_CONFIGURED, ) if not token: raise SentryApiError( f"{ENV_AUTH_TOKEN} is not set; refusing to call Sentry (fail closed)", kind=ERROR_MISSING_TOKEN, ) # -------------------------------------------------------------------------- # HTTP layer (injectable) # -------------------------------------------------------------------------- # http_fn(url, headers, timeout) -> (status, body_bytes, response_headers) HttpFn = Callable[[str, dict[str, str], float], "tuple[int, bytes, dict[str, str]]"] def _default_http_fn( url: str, headers: dict[str, str], timeout: float ) -> tuple[int, bytes, dict[str, str]]: request = urllib.request.Request(url, headers=headers, method="GET") try: with urllib.request.urlopen(request, timeout=timeout) as response: return ( int(response.status), response.read(), {k.lower(): v for k, v in response.headers.items()}, ) except urllib.error.HTTPError as exc: # status is meaningful try: body = exc.read() except Exception: # noqa: BLE001 - body is best-effort only body = b"" return ( int(exc.code), body, {k.lower(): v for k, v in (exc.headers or {}).items()}, ) except urllib.error.URLError as exc: raise SentryApiError( f"Sentry unreachable: {exc.reason}", kind=ERROR_UNAVAILABLE ) from exc except TimeoutError as exc: raise SentryApiError("Sentry request timed out", kind=ERROR_UNAVAILABLE) from exc def parse_next_cursor(link_header: str | None) -> str | None: """Extract the ``rel="next"`` cursor when more results exist.""" if not link_header: return None for part in link_header.split(","): rel = _REL_RE.search(part) if not rel or rel.group(1) != "next": continue results = _RESULTS_RE.search(part) if results and results.group(1).lower() != "true": return None cursor = _CURSOR_RE.search(part) if cursor: return cursor.group(1) return None def _get_json( config: SentryBridgeConfig, path: str, params: dict[str, Any], *, token: str, http_fn: HttpFn | None = None, ) -> tuple[Any, dict[str, str]]: caller = http_fn or _default_http_fn query = urllib.parse.urlencode( {k: v for k, v in params.items() if v not in (None, "")} ) url = f"{config.base_url}{path}" if query: url = f"{url}?{query}" headers = { "Authorization": f"Bearer {token}", "Accept": "application/json", "User-Agent": "gitea-tools-sentry-bridge/1.0", } status, body, response_headers = caller(url, headers, config.timeout) if status in (401, 403): raise SentryApiError( "Sentry rejected the auth token (unauthorized)", kind=ERROR_MISSING_TOKEN, status=status, ) if status >= 500: raise SentryApiError( f"Sentry server error (HTTP {status})", kind=ERROR_UNAVAILABLE, status=status, ) if status >= 400: raise SentryApiError( f"Sentry request failed (HTTP {status})", kind=ERROR_HTTP, status=status ) try: payload = json.loads(body.decode("utf-8") or "null") except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise SentryApiError( f"Sentry returned an unparseable response: {exc}", kind=ERROR_INVALID_RESPONSE, status=status, ) from exc return payload, response_headers # -------------------------------------------------------------------------- # Sanitization # -------------------------------------------------------------------------- def _clean(value: Any) -> str: """Redact secrets, then replace embedded local paths with a category token. ``sentry_observability.sanitize_path`` categorizes a string that *is* a path; it must never be applied to whole free-text fields (it would collapse a title or timestamp to ``"other"``). Here it is applied only to substrings that actually match an absolute path. """ text = incident_bridge.redact_text(value) if not text: return "" return _ABS_PATH_RE.sub( lambda m: f"[path:{sentry_observability.sanitize_path(m.group(0))}]", text ) def sanitize_issue(raw: Any) -> dict[str, Any]: """Project one raw Sentry issue into a sanitized, LLM-safe summary.""" if not isinstance(raw, dict): raise SentryApiError( "Sentry issue payload is not an object", kind=ERROR_INVALID_RESPONSE ) issue_id = raw.get("id") if issue_id is None or str(issue_id).strip() == "": raise SentryApiError( "Sentry issue payload is missing 'id'", kind=ERROR_INVALID_RESPONSE ) metadata = raw.get("metadata") if isinstance(raw.get("metadata"), dict) else {} try: count = int(raw.get("count")) except (TypeError, ValueError): count = None permalink = _clean(raw.get("permalink")) if "[REDACTED]" in permalink: permalink = "" user_count = raw.get("userCount") return { "id": str(issue_id).strip(), "short_id": _clean(raw.get("shortId")) or None, "title": _clean(raw.get("title"))[:200], "culprit": _clean(raw.get("culprit")) or None, "level": _clean(raw.get("level")) or None, "status": str(raw.get("status") or "unresolved").strip().lower() or "unresolved", "count": count, "user_count": user_count if isinstance(user_count, int) else None, "first_seen": _clean(raw.get("firstSeen")) or None, "last_seen": _clean(raw.get("lastSeen")) or None, "permalink": permalink or None, "metadata_value": _clean(metadata.get("value"))[:500] or None, "metadata_type": _clean(metadata.get("type")) or None, } def sanitize_event(raw: Any) -> dict[str, Any]: """Project one raw Sentry event into a sanitized summary.""" if not isinstance(raw, dict): raise SentryApiError( "Sentry event payload is not an object", kind=ERROR_INVALID_RESPONSE ) tags: dict[str, str] = {} raw_tags = raw.get("tags") if isinstance(raw_tags, list): # Sentry events return tags as [{"key": ..., "value": ...}, ...] tags = incident_bridge.sanitize_tags( { t.get("key"): t.get("value") for t in raw_tags if isinstance(t, dict) and t.get("key") } ) elif isinstance(raw_tags, dict): tags = incident_bridge.sanitize_tags(raw_tags) return { "event_id": _clean(raw.get("eventID") or raw.get("id")) or None, "message": _clean(raw.get("message") or raw.get("title"))[:2000] or None, "date_created": _clean(raw.get("dateCreated")) or None, "platform": _clean(raw.get("platform")) or None, "environment": _clean(raw.get("environment")) or None, "release": _clean(raw.get("release")) or None, "tags": tags, } # -------------------------------------------------------------------------- # Reads # -------------------------------------------------------------------------- def list_issues( config: SentryBridgeConfig, *, token: str, query: str = "is:unresolved", limit: int = DEFAULT_PAGE_SIZE, max_pages: int = DEFAULT_MAX_PAGES, cursor: str | None = None, environment: str | None = None, http_fn: HttpFn | None = None, ) -> dict[str, Any]: """List sanitized unresolved Sentry issues, following ``Link`` pagination.""" assert_configured(config, token) page_size = max(1, min(int(limit or DEFAULT_PAGE_SIZE), MAX_PAGE_SIZE)) pages_allowed = max(1, int(max_pages or 1)) issues: list[dict[str, Any]] = [] next_cursor = cursor pages_fetched = 0 for _ in range(pages_allowed): payload, headers = _get_json( config, config.issues_path(), { "query": query, "statsPeriod": config.lookback, "limit": page_size, "cursor": next_cursor, "environment": environment or config.environment, }, token=token, http_fn=http_fn, ) pages_fetched += 1 if payload is None: payload = [] if not isinstance(payload, list): raise SentryApiError( "Sentry issue list response was not a JSON array", kind=ERROR_INVALID_RESPONSE, ) issues.extend(sanitize_issue(item) for item in payload) next_cursor = parse_next_cursor(headers.get("link")) if not next_cursor: break return { "success": True, "issues": issues, "count": len(issues), "pages_fetched": pages_fetched, "next_cursor": next_cursor, "inventory_complete": next_cursor is None, "config": config.as_dict(), "query": query, } def get_issue_events( config: SentryBridgeConfig, issue_id: str, *, token: str, limit: int = 10, http_fn: HttpFn | None = None, ) -> dict[str, Any]: """Fetch sanitized recent events plus the latest event for one issue.""" assert_configured(config, token) if not str(issue_id or "").strip(): raise SentryApiError("issue_id is required", kind=ERROR_INVALID_RESPONSE) issue_key = str(issue_id).strip() payload, _ = _get_json( config, config.issue_events_path(issue_key), {"limit": max(1, min(int(limit or 10), MAX_PAGE_SIZE))}, token=token, http_fn=http_fn, ) if payload is None: payload = [] if not isinstance(payload, list): raise SentryApiError( "Sentry event list response was not a JSON array", kind=ERROR_INVALID_RESPONSE, ) events = [sanitize_event(item) for item in payload] return { "success": True, "issue_id": issue_key, "events": events, "count": len(events), "latest_event": events[0] if events else None, "config": config.as_dict(), } # -------------------------------------------------------------------------- # Observation mapping + policy # -------------------------------------------------------------------------- def observation_from_issue( issue: dict[str, Any], config: SentryBridgeConfig, *, gitea_org: str | None = None, gitea_repo: str | None = None, latest_event: dict[str, Any] | None = None, ) -> dict[str, Any]: """Convert a sanitized Sentry issue into a #612 observation dict.""" tags = dict(latest_event.get("tags") or {}) if isinstance(latest_event, dict) else {} environment = None if isinstance(latest_event, dict): environment = latest_event.get("environment") environment = environment or config.environment observation: dict[str, Any] = { "provider": PROVIDER, "provider_base_url": config.base_url, "provider_org": config.org, "provider_project": config.project, "provider_issue_id": issue.get("id"), "provider_short_id": issue.get("short_id"), "provider_permalink": issue.get("permalink"), "title": issue.get("title"), "culprit": issue.get("culprit"), "summary": issue.get("metadata_value") or issue.get("title"), "level": issue.get("level"), "status": issue.get("status") or "unresolved", "event_count": issue.get("count"), "first_seen": issue.get("first_seen"), "last_seen": issue.get("last_seen"), "environment": environment, "tags": tags, } if gitea_org: observation["gitea_org"] = gitea_org if gitea_repo: observation["gitea_repo"] = gitea_repo return observation def should_bridge_issue( issue: dict[str, Any], config: SentryBridgeConfig ) -> tuple[bool, str]: """Policy gate: is this Sentry issue worth a durable Gitea issue?""" status = str(issue.get("status") or "").strip().lower() if status and status != "unresolved": return False, f"status '{status}' is not unresolved" count = issue.get("count") threshold = int(config.min_events_for_issue or 1) if isinstance(count, int) and count < threshold: return ( False, f"event count {count} below {ENV_MIN_EVENTS} threshold {threshold}", ) return True, "meets bridge policy" # -------------------------------------------------------------------------- # Watchdog # -------------------------------------------------------------------------- def watchdog( db: Any, config: SentryBridgeConfig, *, token: str, apply: bool = False, mappings: Sequence[Any] | None = None, gitea_org: str | None = None, gitea_repo: str | None = None, query: str = "is:unresolved", limit: int = DEFAULT_PAGE_SIZE, max_pages: int = DEFAULT_MAX_PAGES, http_fn: HttpFn | None = None, create_issue_fn: Any = None, reconcile_fn: Callable[..., dict[str, Any]] | None = None, fetch_events: bool = True, ) -> dict[str, Any]: """Scan Sentry and reconcile active incidents into Gitea issues. Dry-run by default. ``apply=True`` additionally requires the bridge to be explicitly enabled via ``MCP_SENTRY_ISSUE_BRIDGE_ENABLED``. """ result: dict[str, Any] = { "success": False, "apply": bool(apply), "scanned": 0, "reconciled": 0, "skipped": 0, "failed": 0, "results": [], "reasons": [], "config": config.as_dict(), "raw_incident_assignable": False, "durable_work_system": "gitea_issues", } if apply and not config.bridge_enabled: result["reasons"].append( f"{ENV_BRIDGE_ENABLED} is not enabled; apply refused (fail closed)" ) result["error_kind"] = ERROR_BRIDGE_DISABLED return result try: listing = list_issues( config, token=token, query=query, limit=limit, max_pages=max_pages, http_fn=http_fn, ) except SentryApiError as exc: result["reasons"].append(str(exc)) result.update(exc.as_dict()) return result reconciler = reconcile_fn or incident_bridge.reconcile_incident result["inventory_complete"] = listing.get("inventory_complete", False) result["pages_fetched"] = listing.get("pages_fetched", 0) for issue in listing.get("issues", []): result["scanned"] += 1 eligible, reason = should_bridge_issue(issue, config) if not eligible: result["skipped"] += 1 result["results"].append( { "sentry_issue_id": issue.get("id"), "action": ( ACTION_SKIPPED_THRESHOLD if "threshold" in reason else ACTION_SKIPPED_STATUS ), "reason": reason, } ) continue latest_event = None if fetch_events: try: events = get_issue_events( config, issue["id"], token=token, limit=1, http_fn=http_fn ) latest_event = events.get("latest_event") except SentryApiError as exc: # Event enrichment is best-effort; the issue itself still bridges. result["reasons"].append( f"event fetch failed for {issue.get('id')}: {exc}" ) observation = observation_from_issue( issue, config, gitea_org=gitea_org, gitea_repo=gitea_repo, latest_event=latest_event, ) try: reconciled = reconciler( db, observation=observation, mappings=list(mappings or []), apply=bool(apply), create_issue_fn=create_issue_fn, ) except Exception as exc: # noqa: BLE001 - one bad issue must not abort the scan result["failed"] += 1 result["results"].append( { "sentry_issue_id": issue.get("id"), "action": ACTION_FAILED, "reason": incident_bridge.redact_text(exc), } ) continue result["reconciled"] += 1 result["results"].append( { "sentry_issue_id": issue.get("id"), "action": ACTION_RECONCILED, "outcome": reconciled.get("outcome"), "gitea_issue": reconciled.get("gitea_issue"), "existing_link": reconciled.get("existing_link"), "reasons": reconciled.get("reasons"), } ) result["success"] = result["failed"] == 0 if not result["results"]: result["reasons"].append("no Sentry issues matched the scan window/policy") return result