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:
@@ -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
|
||||
Reference in New Issue
Block a user