Merge pull request 'feat: Sentry/GlitchTip incident bridge via incident_links (Closes #612)' (#623) from feat/issue-612-incident-bridge into master
This commit was merged in pull request #623.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+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,
|
||||
|
||||
@@ -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)",
|
||||
"",
|
||||
"<!-- mcp-incident-bridge:v1 -->",
|
||||
f"<!-- provider={inc.provider} issue_id={inc.provider_issue_id} -->",
|
||||
"",
|
||||
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")
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user