Add phase-1 observability bridge that turns provider observations into normal Gitea issues and control-plane incident_links rows. Dry-run is default; apply creates/links through sanctioned Gitea issue paths only. Raw incidents remain non-assignable; the #600 allocator sees bridge work only as ordinary Gitea issues. Builds on #613 substrate and #600 allocator. Closes #612
789 lines
28 KiB
Python
789 lines
28 KiB
Python
"""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")
|