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
+403
View File
@@ -1787,6 +1787,7 @@ import lease_lifecycle # noqa: E402
import workflow_dashboard # noqa: E402 # #605 live queue/lease dashboard
import incident_bridge # noqa: E402
import sentry_observability # noqa: E402 (#606 optional Sentry observability)
import sentry_incident_bridge # noqa: E402 (#607 Sentry→Gitea incident bridge)
import agent_temp_artifacts
import issue_lock_worktree # noqa: E402
import issue_lock_provenance # noqa: E402
@@ -18214,6 +18215,408 @@ def gitea_observability_link_issue(
)
def _sentry_bridge_config(
base_url: str | None = None,
sentry_org: str | None = None,
sentry_project: str | None = None,
lookback: str | None = None,
min_events: int | None = None,
) -> "sentry_incident_bridge.SentryBridgeConfig":
"""Env-backed Sentry bridge config with explicit per-call overrides."""
return sentry_incident_bridge.config_with_overrides(
sentry_incident_bridge.load_bridge_config(),
base_url=base_url,
org=sentry_org,
project=sentry_project,
lookback=lookback,
min_events_for_issue=min_events,
)
def _sentry_error_result(exc: "sentry_incident_bridge.SentryApiError") -> dict:
"""Convert a Sentry read failure into a redacted, fail-closed result."""
payload = exc.as_dict()
payload.update({"success": False, "reasons": [str(exc)]})
return payload
def _sentry_find_issue(
config: "sentry_incident_bridge.SentryBridgeConfig",
sentry_issue_id: str,
token: str,
) -> dict | None:
"""Locate one live Sentry issue by id within the configured project."""
wanted = str(sentry_issue_id).strip()
listing = sentry_incident_bridge.list_issues(
config,
token=token,
query=f"issue.id:{wanted}",
limit=1,
max_pages=1,
)
for item in listing.get("issues", []):
if str(item.get("id")) == wanted:
return item
return None
@mcp.tool()
def gitea_sentry_list_issues(
query: str = "is:unresolved",
limit: int = 25,
max_pages: int = 10,
cursor: str | None = None,
environment: str | None = None,
base_url: str | None = None,
sentry_org: str | None = None,
sentry_project: str | None = None,
lookback: str | None = None,
) -> dict:
"""List unresolved Sentry issues from the self-hosted server (#607).
Read-only. The auth token is read from ``SENTRY_AUTH_TOKEN`` in the
environment only and is never returned. Results are sanitized (secrets
redacted, local paths reduced to a category) before leaving this tool.
"""
read_block = _profile_operation_gate("gitea.read")
if read_block:
return {
"success": False,
"issues": [],
"reasons": read_block,
"permission_report": _permission_block_report("gitea.read"),
}
config = _sentry_bridge_config(base_url, sentry_org, sentry_project, lookback)
try:
return sentry_incident_bridge.list_issues(
config,
token=sentry_incident_bridge.resolve_token(),
query=query,
limit=limit,
max_pages=max_pages,
cursor=cursor,
environment=environment,
)
except sentry_incident_bridge.SentryApiError as exc:
result = _sentry_error_result(exc)
result["issues"] = []
return result
@mcp.tool()
def gitea_sentry_get_issue_events(
sentry_issue_id: str,
limit: int = 10,
base_url: str | None = None,
sentry_org: str | None = None,
sentry_project: str | None = None,
) -> dict:
"""Fetch sanitized recent events for one Sentry issue (#607).
Read-only. Returns the latest event plus sanitized tags, environment,
release, and timestamps. Sensitive tag keys are dropped.
"""
read_block = _profile_operation_gate("gitea.read")
if read_block:
return {
"success": False,
"events": [],
"reasons": read_block,
"permission_report": _permission_block_report("gitea.read"),
}
config = _sentry_bridge_config(base_url, sentry_org, sentry_project)
try:
return sentry_incident_bridge.get_issue_events(
config,
sentry_issue_id,
token=sentry_incident_bridge.resolve_token(),
limit=limit,
)
except sentry_incident_bridge.SentryApiError as exc:
result = _sentry_error_result(exc)
result["events"] = []
return result
@mcp.tool()
def gitea_sentry_reconcile_issue(
sentry_issue_id: str,
apply: bool = False,
mappings_json: str | None = None,
config_path: str | None = None,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
base_url: str | None = None,
sentry_org: str | None = None,
sentry_project: str | None = None,
worktree_path: str | None = None,
) -> dict:
"""Reconcile one live Sentry issue into a durable Gitea issue (#607).
Fetches the Sentry issue plus its latest event, converts it into a #612
observation, then delegates dedupe/link/create to the sanctioned
``gitea_observability_reconcile_incident`` path. Dry-run by default.
Gitea remains the source of truth; the raw Sentry incident is never an
assignable control-plane work item.
"""
read_block = _profile_operation_gate("gitea.read")
if read_block:
return {
"success": False,
"apply": bool(apply),
"outcome": incident_bridge.OUTCOME_BLOCKED,
"reasons": read_block,
"permission_report": _permission_block_report("gitea.read"),
"raw_incident_assignable": False,
}
config = _sentry_bridge_config(base_url, sentry_org, sentry_project)
token = sentry_incident_bridge.resolve_token()
try:
issue = _sentry_find_issue(config, sentry_issue_id, token)
if issue is None:
return {
"success": False,
"apply": bool(apply),
"outcome": incident_bridge.OUTCOME_BLOCKED,
"reasons": [
f"Sentry issue {sentry_issue_id} not found in the configured "
"project/window (fail closed)"
],
"raw_incident_assignable": False,
}
latest_event = sentry_incident_bridge.get_issue_events(
config, issue["id"], token=token, limit=1
).get("latest_event")
except sentry_incident_bridge.SentryApiError as exc:
result = _sentry_error_result(exc)
result.update(
{
"apply": bool(apply),
"outcome": incident_bridge.OUTCOME_BLOCKED,
"raw_incident_assignable": False,
}
)
return result
observation = sentry_incident_bridge.observation_from_issue(
issue, config, latest_event=latest_event
)
return gitea_observability_reconcile_incident(
observation_json=json.dumps(observation),
apply=apply,
mappings_json=mappings_json,
config_path=config_path,
remote=remote,
host=host,
org=org,
repo=repo,
worktree_path=worktree_path,
)
@mcp.tool()
def gitea_sentry_link_gitea_issue(
sentry_issue_id: str,
gitea_issue_number: int,
apply: bool = False,
mappings_json: str | None = None,
config_path: str | None = None,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
base_url: str | None = None,
sentry_org: str | None = None,
sentry_project: str | None = None,
) -> dict:
"""Link a live Sentry issue to an existing Gitea issue (#607).
Dry-run by default. ``apply=true`` upserts the durable ``incident_links``
mapping only it never creates a new Gitea issue and never reopens one.
"""
read_block = _profile_operation_gate("gitea.read")
if read_block:
return {
"success": False,
"apply": bool(apply),
"outcome": incident_bridge.OUTCOME_BLOCKED,
"reasons": read_block,
"permission_report": _permission_block_report("gitea.read"),
"raw_incident_assignable": False,
}
config = _sentry_bridge_config(base_url, sentry_org, sentry_project)
try:
issue = _sentry_find_issue(
config, sentry_issue_id, sentry_incident_bridge.resolve_token()
)
except sentry_incident_bridge.SentryApiError as exc:
result = _sentry_error_result(exc)
result.update(
{
"apply": bool(apply),
"outcome": incident_bridge.OUTCOME_BLOCKED,
"raw_incident_assignable": False,
}
)
return result
if issue is None:
return {
"success": False,
"apply": bool(apply),
"outcome": incident_bridge.OUTCOME_BLOCKED,
"reasons": [
f"Sentry issue {sentry_issue_id} not found in the configured "
"project/window (fail closed)"
],
"raw_incident_assignable": False,
}
observation = sentry_incident_bridge.observation_from_issue(issue, config)
return gitea_observability_link_issue(
observation_json=json.dumps(observation),
gitea_issue_number=int(gitea_issue_number),
apply=apply,
mappings_json=mappings_json,
config_path=config_path,
remote=remote,
host=host,
org=org,
repo=repo,
)
@mcp.tool()
def gitea_sentry_watchdog(
apply: bool = False,
query: str = "is:unresolved",
limit: int = 25,
max_pages: int = 10,
min_events: int | None = None,
lookback: str | None = None,
mappings_json: str | None = None,
config_path: str | None = None,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
base_url: str | None = None,
sentry_org: str | None = None,
sentry_project: str | None = None,
worktree_path: str | None = None,
) -> dict:
"""Scan Sentry and create/update durable Gitea issues for incidents (#607).
Dry-run by default. ``apply=true`` additionally requires
``MCP_SENTRY_ISSUE_BRIDGE_ENABLED`` and issue-create permission. Each
incident is deduped through the #612 ``incident_links`` substrate, so a
recurring failure updates one issue instead of creating duplicates.
"""
read_block = _profile_operation_gate("gitea.read")
if read_block:
return {
"success": False,
"apply": bool(apply),
"reasons": read_block,
"permission_report": _permission_block_report("gitea.read"),
"raw_incident_assignable": False,
}
if apply:
create_block = _profile_permission_block(
task_capability_map.required_permission("create_issue"),
number=None,
remote=remote,
host=host,
org=org,
repo=repo,
org_explicit=org is not None,
repo_explicit=repo is not None,
)
if create_block:
return {
"success": False,
"apply": True,
"reasons": create_block.get("reasons")
if isinstance(create_block, dict)
else [str(create_block)],
"raw_incident_assignable": False,
}
config = _sentry_bridge_config(
base_url, sentry_org, sentry_project, lookback, min_events
)
try:
mappings = incident_bridge.load_project_mappings(
config_path=config_path, mappings_json=mappings_json
)
except Exception as exc: # noqa: BLE001
return {
"success": False,
"apply": bool(apply),
"reasons": [incident_bridge.redact_text(exc)],
"raw_incident_assignable": False,
}
try:
db = control_plane_db.ControlPlaneDB()
except Exception as exc: # noqa: BLE001
return {
"success": False,
"apply": bool(apply),
"reasons": [
f"control-plane DB unavailable: {incident_bridge.redact_text(exc)} "
"(fail closed, #613)"
],
"raw_incident_assignable": False,
}
try:
_, o_def, r_def = _resolve(remote, host, org, repo)
except ValueError:
o_def, r_def = org, repo
create_fn = None
if apply:
def create_fn(title, body, labels, g_org, g_repo):
return gitea_create_issue(
title=title,
body=body,
remote=remote,
host=host,
org=g_org,
repo=g_repo,
labels=list(labels),
issue_type="bug",
initial_status="ready",
require_workflow_labels=False,
worktree_path=worktree_path,
)
result = sentry_incident_bridge.watchdog(
db,
config,
token=sentry_incident_bridge.resolve_token(),
apply=bool(apply),
mappings=mappings,
gitea_org=o_def,
gitea_repo=r_def,
query=query,
limit=limit,
max_pages=max_pages,
create_issue_fn=create_fn,
)
result["remote"] = remote
return result
@mcp.tool()
def gitea_allocate_next_work(
apply: bool = False,