feat: Sentry/GlitchTip incident bridge via incident_links (Closes #612)

Add phase-1 observability bridge that turns provider observations into
normal Gitea issues and control-plane incident_links rows. Dry-run is
default; apply creates/links through sanctioned Gitea issue paths only.
Raw incidents remain non-assignable; the #600 allocator sees bridge work
only as ordinary Gitea issues. Builds on #613 substrate and #600 allocator.

Closes #612
This commit is contained in:
2026-07-10 03:18:54 -04:00
parent f20c8436aa
commit 6a179aeb85
6 changed files with 1418 additions and 13 deletions
+214 -10
View File
@@ -835,6 +835,7 @@ import mcp_session_state # noqa: E402
import stale_review_decision_lock # noqa: E402
import allocator_service # noqa: E402
import control_plane_db # noqa: E402
import incident_bridge # noqa: E402
import agent_temp_artifacts
import issue_lock_worktree # noqa: E402
import issue_lock_provenance # noqa: E402
@@ -11244,18 +11245,17 @@ def _allocator_candidates_from_gitea(
# Explicit downstream dependency: #612 waits on #600 allocator.
dep_unmet = False
dep_reason = None
if int(number) == 612:
dep_unmet = True
dep_reason = (
"issue#612 (incident bridge) remains downstream of #600 "
"allocator and must not be allocated until #600 is complete"
)
# Generic body markers for blocked-on unfinished deps.
# (Hard-coded #612→#600 block removed after #600 merged — #612.)
lower_body = body.lower()
if "blocked on #600" in lower_body or "downstream of #600" in lower_body:
dep_unmet = True
dep_reason = dep_reason or (
f"issue#{number} body marks dependency on #600"
if "blocked on #" in lower_body or "downstream of #" in lower_body:
# Only treat as unmet when body still marks a live open dependency
# pattern; callers may clear labels when deps complete.
dep_unmet = "blocked on #" in lower_body
dep_reason = (
f"issue#{number} body marks an unmet dependency"
if dep_unmet
else None
)
try:
candidates.append(
@@ -11279,6 +11279,210 @@ def _allocator_candidates_from_gitea(
return candidates, reasons
@mcp.tool()
def gitea_observability_list_projects(
config_path: str | None = None,
mappings_json: str | None = None,
) -> dict:
"""List configured Sentry/GlitchTip → Gitea project mappings (#612).
Does not load provider tokens. Mappings come from
``GITEA_OBSERVABILITY_PROJECTS_JSON``,
``GITEA_OBSERVABILITY_PROJECTS_FILE``, or explicit arguments.
"""
read_block = _profile_operation_gate("gitea.read")
if read_block:
return {
"success": False,
"projects": [],
"reasons": read_block,
"permission_report": _permission_block_report("gitea.read"),
}
try:
projects = incident_bridge.load_project_mappings(
config_path=config_path, mappings_json=mappings_json
)
except Exception as exc: # noqa: BLE001
return {
"success": False,
"projects": [],
"reasons": [incident_bridge.redact_text(exc)],
}
return {
"success": True,
"projects": [p.as_dict() for p in projects],
"count": len(projects),
"reasons": [] if projects else ["no project mappings configured"],
"note": (
"Provider API tokens are never returned. "
"Raw incidents are not assignable work (#612)."
),
}
@mcp.tool()
def gitea_observability_reconcile_incident(
observation_json: 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,
force_gitea_issue_number: int | None = None,
worktree_path: str | None = None,
) -> dict:
"""Reconcile one Sentry/GlitchTip observation into Gitea + incident_links (#612).
Phase-1: pass a sanitized observation as JSON. Dry-run is default
(``apply=false``) no Gitea mutation, no DB write.
When ``apply=true``:
* reuses existing ``incident_links`` row when present
* otherwise creates a **normal Gitea issue** (never a raw work_item incident)
* upserts the canonical ``incident_links`` row on the #613 control-plane DB
Never assigns raw provider incidents to the #600 allocator.
"""
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,
}
if apply:
create_block = _profile_permission_block(
task_capability_map.required_permission("create_issue"),
number=None,
)
if create_block:
return {
"success": False,
"apply": True,
"outcome": incident_bridge.OUTCOME_BLOCKED,
"reasons": create_block.get("reasons")
if isinstance(create_block, dict)
else [str(create_block)],
"raw_incident_assignable": False,
}
try:
observation = json.loads(observation_json)
except Exception as exc: # noqa: BLE001
return {
"success": False,
"apply": bool(apply),
"outcome": incident_bridge.OUTCOME_BLOCKED,
"reasons": [
f"invalid observation_json: {incident_bridge.redact_text(exc)} "
"(fail closed)"
],
"raw_incident_assignable": False,
}
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),
"outcome": incident_bridge.OUTCOME_BLOCKED,
"reasons": [incident_bridge.redact_text(exc)],
"raw_incident_assignable": False,
}
# Allow org/repo overrides on the observation when not mapped.
if isinstance(observation, dict):
try:
_, o_def, r_def = _resolve(remote, host, org, repo)
except ValueError:
o_def, r_def = org, repo
observation.setdefault("gitea_org", o_def)
observation.setdefault("gitea_repo", r_def)
try:
db = control_plane_db.ControlPlaneDB()
except Exception as exc: # noqa: BLE001
return {
"success": False,
"apply": bool(apply),
"outcome": incident_bridge.OUTCOME_BLOCKED,
"reasons": [
f"control-plane DB unavailable: {incident_bridge.redact_text(exc)} "
"(fail closed, #613)"
],
"raw_incident_assignable": False,
}
create_fn = None
if apply and force_gitea_issue_number is None:
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 = incident_bridge.reconcile_incident(
db,
observation=observation if isinstance(observation, dict) else {},
mappings=mappings,
apply=bool(apply),
create_issue_fn=create_fn,
force_gitea_issue_number=force_gitea_issue_number,
)
result["remote"] = remote
return result
@mcp.tool()
def gitea_observability_link_issue(
observation_json: 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,
) -> dict:
"""Explicitly link a provider observation to an existing Gitea issue (#612).
Dry-run by default. ``apply=true`` upserts ``incident_links`` only does
not create a new issue. Fails closed on mapping/fingerprint conflicts.
"""
return gitea_observability_reconcile_incident(
observation_json=observation_json,
apply=apply,
mappings_json=mappings_json,
config_path=config_path,
remote=remote,
host=host,
org=org,
repo=repo,
force_gitea_issue_number=int(gitea_issue_number),
worktree_path=None,
)
@mcp.tool()
def gitea_allocate_next_work(
apply: bool = False,