feat: Sentry/GlitchTip incident bridge via incident_links (Closes #612)
Add phase-1 observability bridge that turns provider observations into normal Gitea issues and control-plane incident_links rows. Dry-run is default; apply creates/links through sanctioned Gitea issue paths only. Raw incidents remain non-assignable; the #600 allocator sees bridge work only as ordinary Gitea issues. Builds on #613 substrate and #600 allocator. Closes #612
This commit is contained in:
@@ -1149,3 +1149,31 @@ class ControlPlaneDB:
|
|||||||
(gitea_org, gitea_repo, int(gitea_issue_number)),
|
(gitea_org, gitea_repo, int(gitea_issue_number)),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
return dict(row) if row else None
|
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).
|
- `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.
|
- 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).
|
- 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)
|
## Non-goals (intentionally deferred)
|
||||||
|
|
||||||
- Sentry/GlitchTip provider adapters and auto-watchdog — **#612**
|
- Full unsupervised watchdog auto-filing (prefer explicit reconcile first)
|
||||||
- Gitea comment/label mirror writers (may be added by allocator tooling later)
|
- Assuming GlitchTip writeback API equals Sentry without verification
|
||||||
|
- Gitea comment/label mirror writers for every assignment (optional later)
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
|||||||
+214
-10
@@ -835,6 +835,7 @@ import mcp_session_state # noqa: E402
|
|||||||
import stale_review_decision_lock # noqa: E402
|
import stale_review_decision_lock # noqa: E402
|
||||||
import allocator_service # noqa: E402
|
import allocator_service # noqa: E402
|
||||||
import control_plane_db # noqa: E402
|
import control_plane_db # noqa: E402
|
||||||
|
import incident_bridge # noqa: E402
|
||||||
import agent_temp_artifacts
|
import agent_temp_artifacts
|
||||||
import issue_lock_worktree # noqa: E402
|
import issue_lock_worktree # noqa: E402
|
||||||
import issue_lock_provenance # 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.
|
# Explicit downstream dependency: #612 waits on #600 allocator.
|
||||||
dep_unmet = False
|
dep_unmet = False
|
||||||
dep_reason = None
|
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.
|
# Generic body markers for blocked-on unfinished deps.
|
||||||
|
# (Hard-coded #612→#600 block removed after #600 merged — #612.)
|
||||||
lower_body = body.lower()
|
lower_body = body.lower()
|
||||||
if "blocked on #600" in lower_body or "downstream of #600" in lower_body:
|
if "blocked on #" in lower_body or "downstream of #" in lower_body:
|
||||||
dep_unmet = True
|
# Only treat as unmet when body still marks a live open dependency
|
||||||
dep_reason = dep_reason or (
|
# pattern; callers may clear labels when deps complete.
|
||||||
f"issue#{number} body marks dependency on #600"
|
dep_unmet = "blocked on #" in lower_body
|
||||||
|
dep_reason = (
|
||||||
|
f"issue#{number} body marks an unmet dependency"
|
||||||
|
if dep_unmet
|
||||||
|
else None
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
candidates.append(
|
candidates.append(
|
||||||
@@ -11279,6 +11279,210 @@ def _allocator_candidates_from_gitea(
|
|||||||
return candidates, reasons
|
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()
|
@mcp.tool()
|
||||||
def gitea_allocate_next_work(
|
def gitea_allocate_next_work(
|
||||||
apply: bool = False,
|
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",
|
"permission": "gitea.read",
|
||||||
"role": "author",
|
"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": {
|
"reconcile_landed_pr": {
|
||||||
"permission": "gitea.read",
|
"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