Remediates review 479/481 findings F1 and F2 on PR #767. F1: issue #607 AC4 requires that an existing linked Gitea issue receives a recurrence comment when Sentry events continue. That path did not exist. This adds: - incident_bridge.incident_recurred() — true only when event_count or last_seen genuinely advanced, compared against the pre-upsert link row, so an unchanged rescan stays silent and the exactly-once property holds. - incident_bridge.build_recurrence_comment_body() — reuses the same redact_text path as build_gitea_issue_body, so redaction is not re-implemented. - A comment_issue_fn hook on reconcile_incident, invoked only on OUTCOME_UPDATED with genuinely new events. The durable incident_links row is written first, so a comment failure degrades to "link updated, comment withheld" without corrupting the mapping or creating a duplicate issue. - _incident_recurrence_comment_fn() in gitea_mcp_server.py, routing through the sanctioned gitea_create_issue_comment path named in issue #607. It returns None on dry runs and when the profile lacks gitea.issue.comment, so the AC8 dry-run default and disabled-mode safety hold. - comment_issue_fn threading through sentry_incident_bridge.watchdog(), with the per-issue recurrence_comment outcome recorded in the scan result. F2: observation_from_issue never populates a fingerprint, so the "deduped by Sentry issue id and fingerprint" claim was unsupported. The gitea_sentry_reconcile_issue and gitea_sentry_watchdog docstrings now state the real dedupe basis: provider identity (provider, base URL, org, project, Sentry issue id). Tests: five new AC4 cases in tests/test_sentry_incident_bridge.py covering a comment on the second scan, dry-run silence, no-new-events silence, link durability when the comment fails, and comment redaction. Focused suite 43 passed (was 38). Refs #607
719 lines
24 KiB
Python
719 lines
24 KiB
Python
"""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,
|
|
comment_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``.
|
|
|
|
``comment_issue_fn`` carries the sanctioned issue-comment route used for
|
|
AC4 recurrence comments on already-linked issues; dry runs never comment.
|
|
"""
|
|
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,
|
|
comment_issue_fn=comment_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"),
|
|
"recurrence_comment": reconciled.get("recurrence_comment"),
|
|
"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
|