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