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