diff --git a/control_plane_db.py b/control_plane_db.py index de723a3..e456bcc 100644 --- a/control_plane_db.py +++ b/control_plane_db.py @@ -1149,3 +1149,31 @@ class ControlPlaneDB: (gitea_org, gitea_repo, int(gitea_issue_number)), ).fetchone() return dict(row) if row else None + + def get_incident_link_by_provider( + self, + *, + provider: str, + provider_issue_id: str, + provider_base_url: str | None = None, + provider_org: str | None = None, + provider_project: str | None = None, + ) -> dict[str, Any] | None: + """Lookup canonical incident_links row by provider key (#612 / #613).""" + base_url = _norm_scope(provider_base_url) + p_org = _norm_scope(provider_org) + p_project = _norm_scope(provider_project) + with self._tx(immediate=False) as conn: + row = conn.execute( + """ + SELECT * FROM incident_links + WHERE provider = ? + AND provider_base_url = ? + AND provider_org = ? + AND provider_project = ? + AND provider_issue_id = ? + LIMIT 1 + """, + (provider, base_url, p_org, p_project, str(provider_issue_id)), + ).fetchone() + return dict(row) if row else None diff --git a/docs/architecture/control-plane-db-substrate.md b/docs/architecture/control-plane-db-substrate.md index 1947794..e6d0439 100644 --- a/docs/architecture/control-plane-db-substrate.md +++ b/docs/architecture/control-plane-db-substrate.md @@ -49,12 +49,25 @@ Module: `allocator_service.py` · MCP tool: `gitea_allocate_next_work` - `apply=true` reserves via `ControlPlaneDB.assign_and_lease` (atomic assignment+lease). - Coordination source is **always** the control-plane DB — not file locks or comment-only leases. - Routing follows ADR §5.3 (REQUEST_CHANGES → author, terminal path first, foreign lease → wait). -- Raw monitoring incidents are never candidates (#612 remains downstream). +- Raw monitoring incidents are never candidates. + +## Incident bridge (#612) + +Module: `incident_bridge.py` · MCP tools: `gitea_observability_*` + +- Bridge converts Sentry/GlitchTip observations → **normal Gitea issues** + `incident_links`. +- Phase-1: `gitea_observability_reconcile_incident(apply=false|true)` with JSON observation. +- Dry-run performs **no** Gitea mutation and **no** DB write. +- Apply reuses existing links or creates one Gitea issue; never invents `work_items.kind=incident`. +- Config: `GITEA_OBSERVABILITY_PROJECTS_JSON` or `GITEA_OBSERVABILITY_PROJECTS_FILE`. +- Provider tokens never appear in issue bodies, links, or tool results. +- Allocator sees bridge work only after a Gitea issue exists. ## Non-goals (intentionally deferred) -- Sentry/GlitchTip provider adapters and auto-watchdog — **#612** -- Gitea comment/label mirror writers (may be added by allocator tooling later) +- Full unsupervised watchdog auto-filing (prefer explicit reconcile first) +- Assuming GlitchTip writeback API equals Sentry without verification +- Gitea comment/label mirror writers for every assignment (optional later) ## Tests diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index a22faea..9575bcc 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -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, diff --git a/incident_bridge.py b/incident_bridge.py new file mode 100644 index 0000000..1a892da --- /dev/null +++ b/incident_bridge.py @@ -0,0 +1,788 @@ +"""Observability incident bridge (#612). + +Converts Sentry/GlitchTip **observations** into durable **Gitea issues** and +``incident_links`` rows on the #613 control-plane DB. + +Hard rules (ADR): +* Gitea owns work; providers own incidents; the bridge only links them. +* Raw monitoring incidents are **never** assignable ``work_items``. +* The #600 allocator sees bridge work only as normal Gitea issues. +* Phase-1 prefers dry-run / explicit reconcile; apply creates/links issues. +* No tokens, DSNs, cookies, or other secrets in bodies, DB, or logs. +""" + +from __future__ import annotations + +import json +import os +import re +from dataclasses import dataclass, field +from typing import Any, Callable, Sequence + +from control_plane_db import ControlPlaneDB, ControlPlaneError, WORK_KINDS, _norm_scope + +PROVIDERS = frozenset({"sentry", "glitchtip"}) + +OUTCOME_PREVIEW = "preview" +OUTCOME_LINKED = "linked_existing" +OUTCOME_CREATED = "created_issue" +OUTCOME_UPDATED = "updated_link" +OUTCOME_BLOCKED = "blocked" +OUTCOME_NO_ACTION = "no_safe_action" + +# Patterns scrubbed from any bridge-facing text (bodies, titles, tags, logs). +_SECRET_PATTERNS: tuple[re.Pattern[str], ...] = ( + re.compile( + r"(?i)\b(api[_-]?token|token|secret|password|passwd)\s*[:=]\s*\S+" + ), + re.compile( + r"(?i)\bAuthorization\s*[:=]\s*(?:Bearer\s+)?[A-Za-z0-9._\-+=/]{8,}" + ), + re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._\-]{8,}"), + re.compile(r"(?i)\bBasic\s+[A-Za-z0-9+/=]{8,}"), + re.compile(r"(?i)\b(dsn|sentry_dsn|glitchtip_dsn)\s*[:=]\s*\S+"), + re.compile(r"https?://[^/\s]+:[^@/\s]+@"), # user:pass@host + re.compile(r"(?i)\b(keychain[_-]?id|private[_-]?key)\s*[:=]\s*\S+"), + re.compile(r"(?i)cookie\s*[:=]\s*[^\s;]+"), + re.compile(r"(?i)\bsession[_-]?id\s*[:=]\s*\S+"), +) + +_SENSITIVE_TAG_KEYS = frozenset( + { + "authorization", + "cookie", + "set-cookie", + "password", + "passwd", + "secret", + "token", + "api_key", + "apikey", + "dsn", + "sentry_dsn", + "access_token", + "refresh_token", + } +) + +DEFAULT_LABELS = ( + "type:bug", + "observability", + "status:ready", +) + + +@dataclass(frozen=True) +class ProjectMapping: + """Maps a monitoring project to a Gitea repository.""" + + name: str + provider: str + monitor_base_url: str + monitor_org: str + monitor_project: str + gitea_org: str + gitea_repo: str + default_labels: tuple[str, ...] = DEFAULT_LABELS + environment_filters: tuple[str, ...] = () + severity_threshold: str | None = None + + def __post_init__(self) -> None: + prov = (self.provider or "").strip().lower() + if prov not in PROVIDERS: + raise ControlPlaneError( + f"unknown provider '{self.provider}'; expected one of {sorted(PROVIDERS)}" + ) + object.__setattr__(self, "provider", prov) + object.__setattr__(self, "monitor_base_url", (self.monitor_base_url or "").rstrip("/")) + object.__setattr__(self, "monitor_org", (self.monitor_org or "").strip()) + object.__setattr__(self, "monitor_project", (self.monitor_project or "").strip()) + object.__setattr__(self, "gitea_org", (self.gitea_org or "").strip()) + object.__setattr__(self, "gitea_repo", (self.gitea_repo or "").strip()) + object.__setattr__( + self, + "default_labels", + tuple(str(x).strip() for x in (self.default_labels or DEFAULT_LABELS) if str(x).strip()), + ) + + def as_dict(self) -> dict[str, Any]: + return { + "name": self.name, + "provider": self.provider, + "monitor_base_url": self.monitor_base_url, + "monitor_org": self.monitor_org, + "monitor_project": self.monitor_project, + "gitea_org": self.gitea_org, + "gitea_repo": self.gitea_repo, + "default_labels": list(self.default_labels), + "environment_filters": list(self.environment_filters), + "severity_threshold": self.severity_threshold, + } + + def matches_observation(self, obs: dict[str, Any]) -> bool: + if (obs.get("provider") or "").strip().lower() != self.provider: + return False + base = (obs.get("provider_base_url") or obs.get("monitor_base_url") or "").rstrip("/") + if base and self.monitor_base_url and base != self.monitor_base_url: + return False + org = (obs.get("provider_org") or obs.get("monitor_org") or "").strip() + if org and self.monitor_org and org != self.monitor_org: + return False + project = (obs.get("provider_project") or obs.get("monitor_project") or "").strip() + if project and self.monitor_project and project != self.monitor_project: + return False + return True + + +@dataclass +class NormalizedIncident: + provider: str + provider_base_url: str + provider_org: str + provider_project: str + provider_issue_id: str + provider_short_id: str | None = None + provider_permalink: str | None = None + fingerprint: str | None = None + first_seen: str | None = None + last_seen: str | None = None + event_count: int | None = None + status: str = "open" + environment: str | None = None + severity: str | None = None + culprit: str | None = None + title: str = "" + summary: str = "" + tags: dict[str, str] = field(default_factory=dict) + gitea_org: str = "" + gitea_repo: str = "" + default_labels: tuple[str, ...] = DEFAULT_LABELS + + def provider_key(self) -> tuple[str, str, str, str, str]: + return ( + self.provider, + _norm_scope(self.provider_base_url), + _norm_scope(self.provider_org), + _norm_scope(self.provider_project), + str(self.provider_issue_id), + ) + + def as_dict(self) -> dict[str, Any]: + return { + "provider": self.provider, + "provider_base_url": self.provider_base_url, + "provider_org": self.provider_org, + "provider_project": self.provider_project, + "provider_issue_id": self.provider_issue_id, + "provider_short_id": self.provider_short_id, + "provider_permalink": self.provider_permalink, + "fingerprint": self.fingerprint, + "first_seen": self.first_seen, + "last_seen": self.last_seen, + "event_count": self.event_count, + "status": self.status, + "environment": self.environment, + "severity": self.severity, + "culprit": self.culprit, + "title": self.title, + "summary": self.summary, + "tags": dict(self.tags), + "gitea_org": self.gitea_org, + "gitea_repo": self.gitea_repo, + "default_labels": list(self.default_labels), + } + + +def redact_text(value: Any) -> str: + """Redact secret-like material from a string (fail closed to empty).""" + if value is None: + return "" + text = str(value) + for pat in _SECRET_PATTERNS: + text = pat.sub("[REDACTED]", text) + return text + + +def sanitize_tags(tags: Any) -> dict[str, str]: + if not isinstance(tags, dict): + return {} + out: dict[str, str] = {} + for k, v in tags.items(): + key = str(k).strip().lower() + if not key or key in _SENSITIVE_TAG_KEYS: + continue + if any(s in key for s in ("token", "secret", "password", "cookie", "auth", "dsn")): + continue + val = redact_text(v) + if "[REDACTED]" in val: + continue + if len(val) > 200: + val = val[:200] + "…" + out[key] = val + return out + + +def project_mapping_from_dict(data: dict[str, Any]) -> ProjectMapping: + labels = data.get("default_labels") or DEFAULT_LABELS + envs = data.get("environment_filters") or () + return ProjectMapping( + name=str(data.get("name") or data.get("monitor_project") or "unnamed"), + provider=str(data.get("provider") or ""), + monitor_base_url=str(data.get("monitor_base_url") or data.get("provider_base_url") or ""), + monitor_org=str(data.get("monitor_org") or data.get("provider_org") or ""), + monitor_project=str(data.get("monitor_project") or data.get("provider_project") or ""), + gitea_org=str(data.get("gitea_org") or ""), + gitea_repo=str(data.get("gitea_repo") or ""), + default_labels=tuple(labels), + environment_filters=tuple(envs), + severity_threshold=data.get("severity_threshold"), + ) + + +def load_project_mappings( + *, + config_path: str | None = None, + mappings_json: str | None = None, +) -> list[ProjectMapping]: + """Load project mappings from JSON env, file, or explicit JSON string. + + Env: ``GITEA_OBSERVABILITY_PROJECTS_JSON`` or path + ``GITEA_OBSERVABILITY_PROJECTS_FILE``. + """ + raw: Any = None + if mappings_json: + raw = json.loads(mappings_json) + else: + env_json = (os.environ.get("GITEA_OBSERVABILITY_PROJECTS_JSON") or "").strip() + path = ( + (config_path or "").strip() + or (os.environ.get("GITEA_OBSERVABILITY_PROJECTS_FILE") or "").strip() + ) + if env_json: + raw = json.loads(env_json) + elif path and os.path.isfile(path): + with open(path, encoding="utf-8") as fh: + raw = json.load(fh) + if raw is None: + return [] + if isinstance(raw, dict) and "projects" in raw: + raw = raw["projects"] + if not isinstance(raw, list): + raise ControlPlaneError("observability project mappings must be a list") + return [project_mapping_from_dict(item) for item in raw if isinstance(item, dict)] + + +def resolve_mapping( + observation: dict[str, Any], + mappings: Sequence[ProjectMapping], + *, + explicit: ProjectMapping | None = None, +) -> ProjectMapping | None: + if explicit is not None: + return explicit + matches = [m for m in mappings if m.matches_observation(observation)] + if len(matches) == 1: + return matches[0] + if len(matches) > 1: + raise ControlPlaneError( + "ambiguous project mapping for observation: " + + ", ".join(m.name for m in matches) + + " (fail closed, #612)" + ) + # Fallback: observation itself may carry gitea targets + g_org = (observation.get("gitea_org") or "").strip() + g_repo = (observation.get("gitea_repo") or "").strip() + provider = (observation.get("provider") or "").strip().lower() + if g_org and g_repo and provider in PROVIDERS: + return ProjectMapping( + name=f"inline-{provider}", + provider=provider, + monitor_base_url=str( + observation.get("provider_base_url") + or observation.get("monitor_base_url") + or "" + ), + monitor_org=str( + observation.get("provider_org") or observation.get("monitor_org") or "" + ), + monitor_project=str( + observation.get("provider_project") + or observation.get("monitor_project") + or "" + ), + gitea_org=g_org, + gitea_repo=g_repo, + default_labels=tuple(observation.get("default_labels") or DEFAULT_LABELS), + ) + return None + + +def normalize_incident( + observation: dict[str, Any], + mapping: ProjectMapping, +) -> NormalizedIncident: + """Normalize + sanitize a raw provider observation (fail closed).""" + if not isinstance(observation, dict): + raise ControlPlaneError("observation must be a dict (fail closed, #612)") + + provider = (observation.get("provider") or mapping.provider or "").strip().lower() + if provider not in PROVIDERS: + raise ControlPlaneError( + f"unknown provider '{provider}'; expected {sorted(PROVIDERS)} (fail closed)" + ) + if provider != mapping.provider: + raise ControlPlaneError( + f"observation provider '{provider}' does not match mapping " + f"'{mapping.provider}' (fail closed, #612)" + ) + + issue_id = observation.get("provider_issue_id") or observation.get("id") + if issue_id is None or str(issue_id).strip() == "": + raise ControlPlaneError( + "observation missing provider_issue_id (fail closed, #612)" + ) + + base = ( + observation.get("provider_base_url") + or observation.get("monitor_base_url") + or mapping.monitor_base_url + or "" + ) + org = ( + observation.get("provider_org") + or observation.get("monitor_org") + or mapping.monitor_org + or "" + ) + project = ( + observation.get("provider_project") + or observation.get("monitor_project") + or mapping.monitor_project + or "" + ) + + title = redact_text( + observation.get("title") + or observation.get("culprit") + or f"{provider} incident {issue_id}" + ) + meta = observation.get("metadata") + meta_value = meta.get("value") if isinstance(meta, dict) else None + raw_sum = ( + observation.get("summary") + or observation.get("message") + or meta_value + or title + ) + summary = redact_text(raw_sum) + + permalink = observation.get("provider_permalink") or observation.get("permalink") + if permalink: + permalink = redact_text(permalink) + if "[REDACTED]" in permalink: + permalink = None + + tags = sanitize_tags(observation.get("tags") or {}) + event_count = observation.get("event_count") or observation.get("count") + try: + event_count_i = int(event_count) if event_count is not None else None + except (TypeError, ValueError): + event_count_i = None + + return NormalizedIncident( + provider=provider, + provider_base_url=str(base).rstrip("/"), + provider_org=str(org).strip(), + provider_project=str(project).strip(), + provider_issue_id=str(issue_id).strip(), + provider_short_id=redact_text(observation.get("provider_short_id") or observation.get("shortId") or "") + or None, + provider_permalink=permalink, + fingerprint=redact_text(observation.get("fingerprint") or "") or None, + first_seen=str(observation.get("first_seen") or observation.get("firstSeen") or "") + or None, + last_seen=str(observation.get("last_seen") or observation.get("lastSeen") or "") + or None, + event_count=event_count_i, + status=str(observation.get("status") or "open").strip().lower() or "open", + environment=redact_text(observation.get("environment") or "") or None, + severity=redact_text(observation.get("severity") or observation.get("level") or "") + or None, + culprit=redact_text(observation.get("culprit") or "") or None, + title=title[:200], + summary=summary[:2000], + tags=tags, + gitea_org=mapping.gitea_org, + gitea_repo=mapping.gitea_repo, + default_labels=mapping.default_labels, + ) + + +def build_gitea_issue_title(inc: NormalizedIncident) -> str: + env = f" [{inc.environment}]" if inc.environment else "" + sev = f" ({inc.severity})" if inc.severity else "" + return redact_text(f"[obs:{inc.provider}]{env}{sev} {inc.title}")[:180] + + +def build_gitea_issue_body(inc: NormalizedIncident) -> str: + """Sanitized durable issue body (no secrets).""" + lines = [ + "## Observability incident (bridge #612)", + "", + "", + f"", + "", + f"- **provider:** `{inc.provider}`", + f"- **provider_issue_id:** `{inc.provider_issue_id}`", + ] + if inc.provider_short_id: + lines.append(f"- **provider_short_id:** `{inc.provider_short_id}`") + if inc.provider_permalink: + lines.append(f"- **provider_url:** {inc.provider_permalink}") + lines.extend( + [ + f"- **provider_org/project:** `{inc.provider_org}` / `{inc.provider_project}`", + f"- **base_url:** `{inc.provider_base_url}`", + f"- **fingerprint:** `{inc.fingerprint or ''}`", + f"- **first_seen:** `{inc.first_seen or ''}`", + f"- **last_seen:** `{inc.last_seen or ''}`", + f"- **event_count:** `{inc.event_count if inc.event_count is not None else ''}`", + f"- **environment:** `{inc.environment or ''}`", + f"- **severity:** `{inc.severity or ''}`", + f"- **culprit:** `{inc.culprit or ''}`", + f"- **status:** `{inc.status}`", + "", + "### Summary", + "", + redact_text(inc.summary) or "(no summary)", + "", + "### Safe tags", + "", + ] + ) + if inc.tags: + for k, v in sorted(inc.tags.items()): + lines.append(f"- `{k}`: `{v}`") + else: + lines.append("- (none)") + lines.extend( + [ + "", + "### Canonical next action", + "", + "Author: investigate and fix under normal Gitea workflow. " + "Allocator may select this issue as ordinary Gitea work " + "(never as a raw provider incident).", + "", + "### Bridge notes", + "", + "- Raw Sentry/GlitchTip incidents are **not** assignable work items.", + "- Gitea remains the durable work record; DB stores `incident_links` only.", + "- Provider tokens must never appear in this issue.", + ] + ) + return "\n".join(lines) + + +def _link_conflict(existing: dict[str, Any], inc: NormalizedIncident) -> str | None: + """Fail closed if existing link targets a different Gitea issue/repo.""" + eg_org = str(existing.get("gitea_org") or "") + eg_repo = str(existing.get("gitea_repo") or "") + eg_num = existing.get("gitea_issue_number") + if eg_org and eg_org != inc.gitea_org: + return ( + f"existing link points to org '{eg_org}', mapping wants " + f"'{inc.gitea_org}' (fail closed, #612)" + ) + if eg_repo and eg_repo != inc.gitea_repo: + return ( + f"existing link points to repo '{eg_repo}', mapping wants " + f"'{inc.gitea_repo}' (fail closed, #612)" + ) + # fingerprint conflict when both present and disagree + ef = (existing.get("fingerprint") or "").strip() + nf = (inc.fingerprint or "").strip() + if ef and nf and ef != nf: + # Same provider issue id with different fingerprints is ambiguous. + return ( + f"fingerprint conflict for provider issue {inc.provider_issue_id}: " + f"link has '{ef}', observation has '{nf}' (fail closed, #612)" + ) + return None + + +CreateIssueFn = Callable[[str, str, list[str], str, str], dict[str, Any]] +# create_issue_fn(title, body, labels, gitea_org, gitea_repo) -> {"number": int, ...} + + +def reconcile_incident( + db: ControlPlaneDB | None, + *, + observation: dict[str, Any], + mappings: Sequence[ProjectMapping] | None = None, + mapping: ProjectMapping | None = None, + apply: bool = False, + create_issue_fn: CreateIssueFn | None = None, + force_gitea_issue_number: int | None = None, +) -> dict[str, Any]: + """Reconcile one observation into incident_links + optional Gitea issue. + + *apply=False* (default): preview only — no DB mutation, no Gitea mutation. + *apply=True*: upsert link; create Gitea issue when none linked (requires + ``create_issue_fn``) or use ``force_gitea_issue_number`` for explicit link. + + Never creates control-plane ``work_items`` for raw incidents. + """ + base: dict[str, Any] = { + "success": False, + "apply": bool(apply), + "outcome": OUTCOME_NO_ACTION, + "performed": False, + "gitea_mutated": False, + "db_mutated": False, + "raw_incident_assignable": False, + "work_item_kind_would_be": None, + "reasons": [], + "skipped": None, + "incident": None, + "existing_link": None, + "gitea_issue": None, + "action": None, + "mapping": None, + "substrate": "control_plane_db.incident_links", + "durable_work_system": "gitea_issues", + } + + if db is None: + base["reasons"] = [ + "control-plane DB substrate unavailable (fail closed, #613/#612)" + ] + base["outcome"] = OUTCOME_BLOCKED + return base + + try: + maps = list(mappings or []) + resolved = resolve_mapping(observation, maps, explicit=mapping) + if resolved is None: + base["outcome"] = OUTCOME_BLOCKED + base["reasons"] = [ + "no project mapping for observation (fail closed, #612); " + "configure GITEA_OBSERVABILITY_PROJECTS_JSON or pass mapping" + ] + return base + base["mapping"] = resolved.as_dict() + inc = normalize_incident(observation, resolved) + base["incident"] = inc.as_dict() + except (ControlPlaneError, json.JSONDecodeError, TypeError, ValueError) as exc: + base["outcome"] = OUTCOME_BLOCKED + base["reasons"] = [str(exc)] + return base + + # Environment filter (optional) + if resolved.environment_filters and inc.environment: + allowed = {e.lower() for e in resolved.environment_filters} + if inc.environment.lower() not in allowed: + base["outcome"] = OUTCOME_NO_ACTION + base["reasons"] = [ + f"environment '{inc.environment}' not in filters " + f"{sorted(allowed)}; skip (policy)" + ] + base["success"] = True + base["action"] = "skip_environment_filter" + return base + + existing = db.get_incident_link_by_provider( + provider=inc.provider, + provider_issue_id=inc.provider_issue_id, + provider_base_url=inc.provider_base_url, + provider_org=inc.provider_org, + provider_project=inc.provider_project, + ) + base["existing_link"] = existing + + if existing: + conflict = _link_conflict(existing, inc) + if conflict: + base["outcome"] = OUTCOME_BLOCKED + base["reasons"] = [conflict] + return base + + title = build_gitea_issue_title(inc) + body = build_gitea_issue_body(inc) + labels = list(inc.default_labels) + if inc.provider and f"{inc.provider}" not in labels: + labels.append(inc.provider) + if "observability" not in labels: + labels.append("observability") + + preview_issue = { + "title": title, + "body_preview": body[:500] + ("…" if len(body) > 500 else ""), + "labels": labels, + "gitea_org": inc.gitea_org, + "gitea_repo": inc.gitea_repo, + "would_create": existing is None and force_gitea_issue_number is None, + "would_reuse_issue": int(existing["gitea_issue_number"]) + if existing + else force_gitea_issue_number, + } + base["gitea_issue_preview"] = preview_issue + + if not apply: + base["success"] = True + base["outcome"] = OUTCOME_PREVIEW + base["action"] = ( + "preview_reuse_link" if existing else "preview_create_issue" + ) + base["reasons"] = [ + "dry-run only (apply=false); no Gitea mutation and no DB write — " + "call again with apply=true to create/link" + ] + if existing: + base["gitea_issue"] = { + "number": existing.get("gitea_issue_number"), + "org": existing.get("gitea_org"), + "repo": existing.get("gitea_repo"), + } + # Prove we never treat this as work_item kind incident + base["raw_incident_assignable"] = False + base["work_item_kind_would_be"] = "issue" # only after Gitea issue exists + return base + + # --- apply path --- + issue_number: int | None = None + created = False + if existing: + issue_number = int(existing["gitea_issue_number"]) + action = "updated_existing_link" + outcome = OUTCOME_UPDATED + elif force_gitea_issue_number is not None: + issue_number = int(force_gitea_issue_number) + action = "link_explicit_issue" + outcome = OUTCOME_LINKED + else: + if create_issue_fn is None: + base["outcome"] = OUTCOME_BLOCKED + base["reasons"] = [ + "apply=true requires create_issue_fn when no existing link " + "(fail closed, #612)" + ] + return base + try: + created_res = create_issue_fn( + title, body, labels, inc.gitea_org, inc.gitea_repo + ) + except Exception as exc: # noqa: BLE001 + base["outcome"] = OUTCOME_BLOCKED + base["reasons"] = [ + f"Gitea issue creation failed: {redact_text(exc)} (fail closed)" + ] + return base + number = created_res.get("number") if isinstance(created_res, dict) else None + if number is None: + base["outcome"] = OUTCOME_BLOCKED + base["reasons"] = [ + "Gitea issue creation returned no issue number (fail closed, #612)" + ] + base["create_result"] = created_res + return base + issue_number = int(number) + created = True + action = "created_gitea_issue" + outcome = OUTCOME_CREATED + base["gitea_mutated"] = True + base["create_result"] = { + k: created_res.get(k) + for k in ("number", "success", "performed", "reasons") + if isinstance(created_res, dict) + } + + assert issue_number is not None + try: + link = db.upsert_incident_link( + provider=inc.provider, + provider_issue_id=inc.provider_issue_id, + gitea_org=inc.gitea_org, + gitea_repo=inc.gitea_repo, + gitea_issue_number=issue_number, + provider_base_url=inc.provider_base_url, + provider_org=inc.provider_org, + provider_project=inc.provider_project, + provider_short_id=inc.provider_short_id, + provider_permalink=inc.provider_permalink, + fingerprint=inc.fingerprint, + first_seen=inc.first_seen, + last_seen=inc.last_seen, + event_count=inc.event_count, + status=inc.status if not created else "open", + ) + except Exception as exc: # noqa: BLE001 + base["outcome"] = OUTCOME_BLOCKED + base["reasons"] = [ + f"incident_links upsert failed: {redact_text(exc)} (fail closed, #613)" + ] + base["gitea_issue"] = { + "number": issue_number, + "org": inc.gitea_org, + "repo": inc.gitea_repo, + "created": created, + } + return base + + base["success"] = True + base["performed"] = True + base["db_mutated"] = True + base["outcome"] = outcome + base["action"] = action + base["gitea_issue"] = { + "number": issue_number, + "org": inc.gitea_org, + "repo": inc.gitea_repo, + "created": created, + } + base["link"] = { + k: link.get(k) + for k in ( + "link_id", + "provider", + "provider_issue_id", + "gitea_org", + "gitea_repo", + "gitea_issue_number", + "fingerprint", + "event_count", + "status", + "last_sync_at", + ) + } + base["reasons"] = [ + ( + f"reused Gitea issue #{issue_number} for provider " + f"{inc.provider}:{inc.provider_issue_id}" + if not created + else f"created Gitea issue #{issue_number} and stored incident_links row" + ), + "raw provider incident is not an assignable work_item " + f"(WORK_KINDS={sorted(WORK_KINDS)})", + ] + base["raw_incident_assignable"] = False + base["work_item_kind_would_be"] = "issue" + base["allocator_visible_as"] = { + "kind": "issue", + "number": issue_number, + "org": inc.gitea_org, + "repo": inc.gitea_repo, + "note": "allocator selects normal Gitea issues only (#600/#612)", + } + return base + + +def assert_not_raw_incident_work_item(kind: str) -> None: + """Guard used by tests / callers: incidents must not enter WORK_KINDS.""" + k = (kind or "").strip().lower() + if k not in WORK_KINDS: + if k in ("sentry_incident", "glitchtip_incident", "incident", "observation"): + raise ControlPlaneError( + f"kind '{k}' is not assignable; bridge must create Gitea issues first (#612)" + ) + raise ControlPlaneError(f"kind '{k}' is not assignable") diff --git a/task_capability_map.py b/task_capability_map.py index 1ee097d..0c17261 100644 --- a/task_capability_map.py +++ b/task_capability_map.py @@ -149,6 +149,33 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = { "permission": "gitea.read", "role": "author", }, + # #612 incident bridge — reconcile uses create_issue for apply; + # dry-run needs read only. Tools gate apply paths themselves. + "observability_reconcile_incident": { + "permission": "gitea.read", + "role": "author", + }, + "gitea_observability_reconcile_incident": { + "permission": "gitea.read", + "role": "author", + }, + "observability_list_projects": { + "permission": "gitea.read", + "role": "author", + }, + "gitea_observability_list_projects": { + "permission": "gitea.read", + "role": "author", + }, + "observability_link_issue": { + "permission": "gitea.read", + "role": "author", + }, + "gitea_observability_link_issue": { + "permission": "gitea.read", + "role": "author", + }, + "reconcile_landed_pr": { "permission": "gitea.read", diff --git a/tests/test_incident_bridge.py b/tests/test_incident_bridge.py new file mode 100644 index 0000000..0f0ac57 --- /dev/null +++ b/tests/test_incident_bridge.py @@ -0,0 +1,345 @@ +"""Tests for observability incident bridge (#612) on #613 substrate.""" + +from __future__ import annotations + +import json +import os +import tempfile +import unittest + +from control_plane_db import ControlPlaneDB, InvalidWorkKindError, WORK_KINDS +from incident_bridge import ( + OUTCOME_BLOCKED, + OUTCOME_CREATED, + OUTCOME_LINKED, + OUTCOME_PREVIEW, + OUTCOME_UPDATED, + ProjectMapping, + assert_not_raw_incident_work_item, + build_gitea_issue_body, + load_project_mappings, + normalize_incident, + project_mapping_from_dict, + reconcile_incident, + redact_text, + sanitize_tags, +) + + +def _mapping(**kwargs) -> ProjectMapping: + base = dict( + name="gitea-tools-mcp", + provider="sentry", + monitor_base_url="https://sentry.prgs.cc", + monitor_org="prgs", + monitor_project="gitea-tools-mcp", + gitea_org="Scaled-Tech-Consulting", + gitea_repo="Gitea-Tools", + default_labels=("type:bug", "observability", "sentry", "status:ready"), + ) + base.update(kwargs) + return ProjectMapping(**base) + + +def _obs(**kwargs) -> dict: + base = dict( + provider="sentry", + provider_base_url="https://sentry.prgs.cc", + provider_org="prgs", + provider_project="gitea-tools-mcp", + provider_issue_id="ISSUE-100", + provider_short_id="GITEA-TOOLS-1A", + provider_permalink="https://sentry.prgs.cc/organizations/prgs/issues/100/", + fingerprint="fp-abc", + title="TypeError: boom", + summary="TypeError: boom in handle_request", + first_seen="2026-07-01T00:00:00Z", + last_seen="2026-07-10T00:00:00Z", + event_count=3, + environment="production", + severity="error", + culprit="handle_request", + tags={"runtime": "python", "release": "1.2.3"}, + ) + base.update(kwargs) + return base + + +class IncidentBridgeTest(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3")) + self.mapping = _mapping() + self.created: list[dict] = [] + + def tearDown(self) -> None: + self._tmp.cleanup() + + def _create_issue(self, title, body, labels, g_org, g_repo): + n = 9000 + len(self.created) + rec = { + "number": n, + "success": True, + "performed": True, + "title": title, + "body": body, + "labels": labels, + "org": g_org, + "repo": g_repo, + } + self.created.append(rec) + return rec + + def test_redact_secrets(self) -> None: + dirty = "token=supersecret123 Authorization: Bearer abcdefghijklmnop" + clean = redact_text(dirty) + self.assertNotIn("supersecret", clean) + self.assertNotIn("abcdefghijklmnop", clean) + self.assertIn("[REDACTED]", clean) + tags = sanitize_tags( + {"token": "x", "runtime": "py", "password": "nope", "release": "1.0"} + ) + self.assertEqual(tags, {"runtime": "py", "release": "1.0"}) + + def test_body_contains_no_secrets(self) -> None: + inc = normalize_incident( + _obs( + summary="fail password=hunter2 token: abc", + tags={"cookie": "sid=1", "runtime": "py"}, + ), + self.mapping, + ) + body = build_gitea_issue_body(inc) + self.assertNotIn("hunter2", body) + self.assertNotIn("sid=1", body) + self.assertIn("provider_issue_id", body) + self.assertIn("not** assignable", body.lower()) + + def test_preview_no_mutation(self) -> None: + res = reconcile_incident( + self.db, + observation=_obs(), + mapping=self.mapping, + apply=False, + create_issue_fn=self._create_issue, + ) + self.assertTrue(res["success"]) + self.assertEqual(res["outcome"], OUTCOME_PREVIEW) + self.assertFalse(res["gitea_mutated"]) + self.assertFalse(res["db_mutated"]) + self.assertFalse(res["raw_incident_assignable"]) + self.assertEqual(len(self.created), 0) + self.assertIsNone( + self.db.get_incident_link_by_provider( + provider="sentry", + provider_issue_id="ISSUE-100", + provider_base_url="https://sentry.prgs.cc", + provider_org="prgs", + provider_project="gitea-tools-mcp", + ) + ) + + def test_apply_creates_issue_and_link(self) -> None: + res = reconcile_incident( + self.db, + observation=_obs(), + mapping=self.mapping, + apply=True, + create_issue_fn=self._create_issue, + ) + self.assertTrue(res["success"]) + self.assertEqual(res["outcome"], OUTCOME_CREATED) + self.assertTrue(res["gitea_mutated"]) + self.assertTrue(res["db_mutated"]) + self.assertEqual(len(self.created), 1) + self.assertEqual(res["gitea_issue"]["number"], self.created[0]["number"]) + link = self.db.get_incident_link_by_provider( + provider="sentry", + provider_issue_id="ISSUE-100", + provider_base_url="https://sentry.prgs.cc", + provider_org="prgs", + provider_project="gitea-tools-mcp", + ) + self.assertIsNotNone(link) + self.assertEqual(int(link["gitea_issue_number"]), self.created[0]["number"]) + self.assertEqual(res["allocator_visible_as"]["kind"], "issue") + + def test_duplicate_observation_reuses_issue(self) -> None: + first = reconcile_incident( + self.db, + observation=_obs(), + mapping=self.mapping, + apply=True, + create_issue_fn=self._create_issue, + ) + second = reconcile_incident( + self.db, + observation=_obs(event_count=9, last_seen="2026-07-11T00:00:00Z"), + mapping=self.mapping, + apply=True, + create_issue_fn=self._create_issue, + ) + self.assertEqual(first["outcome"], OUTCOME_CREATED) + self.assertEqual(second["outcome"], OUTCOME_UPDATED) + self.assertEqual(len(self.created), 1) + self.assertEqual( + second["gitea_issue"]["number"], first["gitea_issue"]["number"] + ) + link = self.db.get_incident_link_by_provider( + provider="sentry", + provider_issue_id="ISSUE-100", + provider_base_url="https://sentry.prgs.cc", + provider_org="prgs", + provider_project="gitea-tools-mcp", + ) + self.assertEqual(int(link["event_count"]), 9) + + def test_explicit_link_existing_issue(self) -> None: + res = reconcile_incident( + self.db, + observation=_obs(provider_issue_id="ISSUE-200"), + mapping=self.mapping, + apply=True, + force_gitea_issue_number=555, + create_issue_fn=self._create_issue, + ) + self.assertEqual(res["outcome"], OUTCOME_LINKED) + self.assertEqual(len(self.created), 0) + self.assertEqual(res["gitea_issue"]["number"], 555) + link = self.db.get_incident_link_for_gitea_issue( + gitea_org="Scaled-Tech-Consulting", + gitea_repo="Gitea-Tools", + gitea_issue_number=555, + ) + self.assertIsNotNone(link) + + def test_fingerprint_conflict_fails_closed(self) -> None: + reconcile_incident( + self.db, + observation=_obs(fingerprint="fp-a"), + mapping=self.mapping, + apply=True, + create_issue_fn=self._create_issue, + ) + res = reconcile_incident( + self.db, + observation=_obs(fingerprint="fp-b"), + mapping=self.mapping, + apply=True, + create_issue_fn=self._create_issue, + ) + self.assertEqual(res["outcome"], OUTCOME_BLOCKED) + self.assertIn("fingerprint conflict", res["reasons"][0].lower()) + self.assertEqual(len(self.created), 1) + + def test_ambiguous_mapping_fails_closed(self) -> None: + maps = [ + _mapping(name="a", monitor_project="gitea-tools-mcp"), + _mapping(name="b", monitor_project="gitea-tools-mcp"), + ] + res = reconcile_incident( + self.db, + observation=_obs(), + mappings=maps, + apply=False, + ) + self.assertEqual(res["outcome"], OUTCOME_BLOCKED) + self.assertIn("ambiguous", res["reasons"][0].lower()) + + def test_missing_provider_issue_id_fails_closed(self) -> None: + res = reconcile_incident( + self.db, + observation=_obs(provider_issue_id=""), + mapping=self.mapping, + apply=False, + ) + self.assertEqual(res["outcome"], OUTCOME_BLOCKED) + + def test_raw_incident_not_work_kind(self) -> None: + self.assertNotIn("sentry_incident", WORK_KINDS) + with self.assertRaises(InvalidWorkKindError): + self.db.upsert_work_item( + remote="prgs", + org="o", + repo="r", + kind="sentry_incident", + number=1, + ) + with self.assertRaises(Exception): + assert_not_raw_incident_work_item("glitchtip_incident") + + def test_db_unavailable(self) -> None: + res = reconcile_incident( + None, + observation=_obs(), + mapping=self.mapping, + apply=True, + create_issue_fn=self._create_issue, + ) + self.assertFalse(res["success"]) + self.assertEqual(res["outcome"], OUTCOME_BLOCKED) + + def test_structured_skip_reason_environment(self) -> None: + m = _mapping(environment_filters=("staging",)) + res = reconcile_incident( + self.db, + observation=_obs(environment="production"), + mapping=m, + apply=True, + create_issue_fn=self._create_issue, + ) + self.assertTrue(res["success"]) + self.assertEqual(res["action"], "skip_environment_filter") + self.assertEqual(len(self.created), 0) + + def test_load_mappings_json(self) -> None: + payload = json.dumps( + { + "projects": [ + { + "name": "gt", + "provider": "glitchtip", + "monitor_base_url": "https://glitchtip.example", + "monitor_org": "org", + "monitor_project": "proj", + "gitea_org": "Scaled-Tech-Consulting", + "gitea_repo": "Gitea-Tools", + } + ] + } + ) + maps = load_project_mappings(mappings_json=payload) + self.assertEqual(len(maps), 1) + self.assertEqual(maps[0].provider, "glitchtip") + + def test_glitchtip_provider_accepted(self) -> None: + m = _mapping(provider="glitchtip", monitor_base_url="https://glitchtip.example") + res = reconcile_incident( + self.db, + observation=_obs( + provider="glitchtip", + provider_base_url="https://glitchtip.example", + ), + mapping=m, + apply=True, + create_issue_fn=self._create_issue, + ) + self.assertEqual(res["outcome"], OUTCOME_CREATED) + self.assertEqual(res["incident"]["provider"], "glitchtip") + + def test_allocator_only_sees_issue_kind(self) -> None: + res = reconcile_incident( + self.db, + observation=_obs(), + mapping=self.mapping, + apply=True, + create_issue_fn=self._create_issue, + ) + vis = res["allocator_visible_as"] + self.assertEqual(vis["kind"], "issue") + self.assertIn(vis["kind"], WORK_KINDS) + self.assertFalse(res["raw_incident_assignable"]) + + +if __name__ == "__main__": + unittest.main()