feat: add Sentry-to-Gitea incident bridge for MCP workflow failures (Closes #607)

Adds the read half of the inbound observability path: pull unresolved issues
and events from the self-hosted Sentry API, normalize them into #612
observations, and reconcile them into durable Gitea issues.

Design: the existing #612 incident_bridge already owns dedupe, linking,
redaction, and issue creation on the #613 incident_links substrate, so this
change adds only what was genuinely missing - a Sentry API read layer,
observation mapping, a policy gate, and a watchdog. No second linking store is
introduced, which is what makes the mapping survive restarts (AC6).

New module sentry_incident_bridge.py:
* list_issues() with Link-header cursor pagination and statsPeriod windowing
* get_issue_events() returning sanitized recent + latest event
* observation_from_issue() mapping onto the #612 observation contract
* should_bridge_issue() policy gate (unresolved + event-count threshold)
* watchdog() scanning and reconciling, dry-run by default
* HTTP access injected as http_fn so the surface is testable without Sentry

New MCP tools: gitea_sentry_list_issues, gitea_sentry_get_issue_events,
gitea_sentry_reconcile_issue, gitea_sentry_link_gitea_issue,
gitea_sentry_watchdog.

Safety:
* SENTRY_AUTH_TOKEN is read from the environment only and never returned,
  logged, or stored; config projections cannot carry it by construction
* missing config fails closed as not_configured, and a missing token fails
  closed as missing_token before any HTTP call is made
* apply=true requires both MCP_SENTRY_ISSUE_BRIDGE_ENABLED and issue-create
  permission; Sentry outages create nothing
* secrets are redacted and absolute local paths reduced to a category token
  before any value can reach a Gitea issue body
* raw Sentry incidents are never assignable control-plane work items

Tests: 38 new cases covering create, update/recurrence, dedupe, resolved-issue
non-reopen, redaction, pagination, missing token, unavailable server,
self-hosted base URL, restart persistence, policy gates, and the five tool
wrappers.

Full suite: 3733 passed, 6 skipped. The 2 remaining failures
(test_issue_702_review_findings_f1_f6, test_reconciler_supersession_close) were
verified to fail identically on unmodified master at fcf6981 and are unrelated
to this change.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_011w1WGVV3duWEf45SJRJ1DL
This commit is contained in:
2026-07-19 22:35:18 -04:00
co-authored by Claude Opus 4.8
parent fcf6981b1b
commit e168978579
4 changed files with 1717 additions and 1 deletions
+712
View File
@@ -0,0 +1,712 @@
"""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