Compare commits

..
13 changed files with 182 additions and 739 deletions
-26
View File
@@ -1568,32 +1568,6 @@ class ControlPlaneDB:
).fetchone()
return dict(row) if row else None
def list_incident_links(
self,
*,
provider: str | None = None,
gitea_org: str | None = None,
gitea_repo: str | None = None,
limit: int = 100,
) -> list[dict[str, Any]]:
"""List stored incident_links rows, optionally filtered by provider/repo (#612 / #649)."""
query = "SELECT * FROM incident_links WHERE 1=1"
params: list[Any] = []
if provider:
query += " AND provider = ?"
params.append(provider.strip().lower())
if gitea_org:
query += " AND gitea_org = ?"
params.append(_norm_scope(gitea_org))
if gitea_repo:
query += " AND gitea_repo = ?"
params.append(_norm_scope(gitea_repo))
query += " ORDER BY link_id DESC LIMIT ?"
params.append(max(1, limit))
with self._tx(immediate=False) as conn:
rows = conn.execute(query, params).fetchall()
return [dict(r) for r in rows]
# ── lease lifecycle (#601) ────────────────────────────────────────────
@@ -1,35 +0,0 @@
# Web Console: Sentry/GlitchTip Observability & Incident Bridge Console (#649)
This document describes the Phase 4 observability console surface integrated into the MCP Control Plane Web Console (`webui/`), backed by the #612 incident bridge and the #613 control-plane DB substrate.
## Architectural Authority Model (ADR Alignment)
Per the Web Console Architecture ADR (`docs/architecture/webui-control-plane-console-architecture-adr.md`):
| Layer | Responsibility | Authority |
|---|---|---|
| **Gitea** | Durable work record | Issues, PRs, comments, reviews, labels, merges |
| **Control-plane DB** | Live coordination & linkage | `incident_links` table, session leases, allocations |
| **Sentry / GlitchTip** | Observability input | Unresolved incidents, error events, stack traces |
| **Incident Bridge (#612)** | Reconciliation engine | Reconciles provider observations into Gitea issues |
| **Web Console (`webui/`)** | Read-only projection & gated actions | Projects connection health & correlation links; gates writes |
> **Key Rule:** Raw monitoring incidents are **never** assignable control-plane `work_items`. They remain observation input only.
## Redaction Boundary Invariants
1. **No secrets in returns or rendering:** Auth tokens (`SENTRY_AUTH_TOKEN`, `GLITCHTIP_AUTH_TOKEN`), DSNs, `Authorization` headers, and sensitive local file paths are passed through `webui.console_redaction` before leaving the server.
2. **Safe projection:** Connection objects report `credentials_present: true/false` rather than exposing raw keys or headers.
## Console Endpoints
- **HTML Surface:** `GET /observability` — Renders provider connection cards, error correlation tables, and gated reconcile controls.
- **Versioned API:** `GET /api/v1/observability` — Returns structured JSON snapshot with `schema_version`, `providers`, `links`, and `metrics`.
- **Legacy Compatibility Alias:** `GET /api/observability` — Read-only compatibility alias for Phase 4.
## Gated Actions
- `observability_reconcile_incident` (`gitea_observability_reconcile_incident`): Triggers or previews dry-run issue reconciliation for a provider incident.
- `observability_link_issue` (`gitea_observability_link_issue`): Links a provider incident to an existing Gitea tracking issue.
Both actions require `operator` role and gate through `task_capability_map`. Execution fails closed in read-only MVP mode.
-2
View File
@@ -98,8 +98,6 @@ already define, and a regression test asserts each mapping matches.
| `system.rebind_session_worktree` | operator | gated_write | `gitea.read` | Yes | No | No | 2 |
| `system.reconcile_cleanups` | controller | privileged | `gitea.pr.close` | Yes | No | No | 2 |
| `initiate_workflow` | operator | gated_write | `gitea.read` | Yes | No | No | 2 |
| `observability_reconcile_incident` | operator | gated_write | `gitea.read` | Yes | No | No | 4 |
| `observability_link_issue` | operator | gated_write | `gitea.read` | Yes | No | No | 4 |
**Dual control** means the acting principal may not be the sole authority: a
second distinct principal must confirm. **Break-glass** means the action is
+28 -11
View File
@@ -3325,10 +3325,16 @@ def _effective_remote(remote: str) -> str:
return remote
def _resolve(remote: str, host: str | None, org: str | None, repo: str | None):
def _resolve(
remote: str,
host: str | None,
org: str | None,
repo: str | None,
for_mutation: bool = False,
):
"""Resolve remote + overrides to (host, org, repo).
#714 / #530: when the caller omits org and/or repo, prefer the
#714 / #530 / #707: when the caller omits org and/or repo, prefer the
workspace-aligned git remote over ``REMOTES`` defaults (e.g. bare
``remote=prgs`` must not force ``Timesheet`` when the checkout is
``Gitea-Tools``). Explicit caller org/repo always win and are validated
@@ -3414,6 +3420,7 @@ def _resolve(remote: str, host: str | None, org: str | None, repo: str | None):
# Workspace-filled sides are intentional alignment for #530.
org_explicit=org_explicit or filled_org,
repo_explicit=repo_explicit or filled_repo,
for_mutation=for_mutation,
)
return (resolved_host, resolved_org, resolved_repo)
@@ -3475,8 +3482,9 @@ def _enforce_remote_repo_guard(
*,
org_explicit: bool,
repo_explicit: bool,
for_mutation: bool = False,
) -> None:
"""Fail closed on a remote/repo mismatch vs. the local git remote (#530).
"""Fail closed on a remote/repo mismatch vs. the local git remote (#530/#707).
Best-effort: bypassed under pytest unless ``GITEA_FORCE_REMOTE_REPO_CHECK`` is
set, so the unit suite (which calls tools with bare remotes against mocked APIs)
@@ -3488,6 +3496,12 @@ def _enforce_remote_repo_guard(
):
return
local_remote_url = _local_git_remote_url(remote)
primary_org = None
primary_repo = None
ctx = session_ctx.get_session_context()
if ctx:
primary_org = ctx.get("org")
primary_repo = ctx.get("repository")
assessment = remote_repo_guard.assess_remote_repo_match(
remote=remote,
resolved_org=resolved_org,
@@ -3495,6 +3509,9 @@ def _enforce_remote_repo_guard(
local_remote_url=local_remote_url,
org_explicit=org_explicit,
repo_explicit=repo_explicit,
for_mutation=for_mutation,
primary_org=primary_org,
primary_repo=primary_repo,
)
if assessment["block"]:
raise RuntimeError(remote_repo_guard.format_remote_repo_guard_error(assessment))
@@ -4063,7 +4080,7 @@ def gitea_lock_issue(
resolved_worktree = issue_lock_worktree.resolve_author_worktree_path(
worktree_path, _canonical_local_git_root()
)
h, o, r = _resolve(remote, host, org, repo)
h, o, r = _resolve(remote, host, org, repo, for_mutation=True)
existing_issue_lock = _load_existing_issue_lock(
remote=remote, org=o, repo=r, issue_number=issue_number
)
@@ -5239,7 +5256,7 @@ def gitea_create_pr(
org=org,
repo=repo,
)
h, o, r = _resolve(remote, host, org, repo)
h, o, r = _resolve(remote, host, org, repo, for_mutation=True)
# ── Issue Lock Validation (Issue #194 / #196 / #443) ──
lock_data = _resolve_issue_lock_for_pr(remote=remote, org=o, repo=r, head=head)
@@ -9560,7 +9577,7 @@ def gitea_edit_pr(
required_permission="gitea.pr.close",
)
h, o, r = _resolve(remote, host, org, repo)
h, o, r = _resolve(remote, host, org, repo, for_mutation=True)
auth = _auth(h)
url = f"{repo_api_url(h, o, r)}/pulls/{pr_number}"
@@ -9844,7 +9861,7 @@ def gitea_commit_files(
verify_preflight_purity(remote=remote, worktree_path=worktree_path, task="commit_files", org=org, repo=repo)
processed_files, source_proofs = _prepare_commit_payload_files(files)
h, o, r = _resolve(remote, host, org, repo)
h, o, r = _resolve(remote, host, org, repo, for_mutation=True)
auth = _auth(h)
url = f"{repo_api_url(h, o, r)}/contents"
@@ -9980,7 +9997,7 @@ def gitea_publish_unpublished_issue_branch(
repo=repo,
)
h, o, r = _resolve(remote, host, org, repo)
h, o, r = _resolve(remote, host, org, repo, for_mutation=True)
git_remote = (git_remote_name or remote or "").strip()
existing_lock = issue_lock_store.load_issue_lock(
@@ -10183,7 +10200,7 @@ def gitea_bootstrap_author_issue_worktree(
repo=repo,
)
h, o, r = _resolve(remote, host, org, repo)
h, o, r = _resolve(remote, host, org, repo, for_mutation=True)
canonical_root = _canonical_local_git_root()
import author_issue_bootstrap
@@ -12131,7 +12148,7 @@ def gitea_reconcile_merged_cleanups(
"blocker_kind": "invalid_pr_number",
}
h, o, r = _resolve(remote, host, org, repo)
h, o, r = _resolve(remote, host, org, repo, for_mutation=True)
auth = _auth(h)
base = repo_api_url(h, o, r)
@@ -12521,7 +12538,7 @@ def gitea_assess_already_landed_reconciliation(
"permission_report": _permission_block_report("gitea.read"),
}
h, o, r = _resolve(remote, host, org, repo)
h, o, r = _resolve(remote, host, org, repo, for_mutation=True)
auth = _auth(h)
pr = api_request("GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth)
+60 -7
View File
@@ -54,20 +54,62 @@ def assess_remote_repo_match(
local_remote_url: str | None,
org_explicit: bool,
repo_explicit: bool,
for_mutation: bool = False,
primary_org: str | None = None,
primary_repo: str | None = None,
) -> dict:
"""Fail closed when the resolved org/repo disagrees with the local git remote.
The guard is intentionally conservative:
The guard enforces two protection levels:
* When the caller passed both ``org`` and ``repo`` explicitly, their intent is
authoritative and the guard never blocks.
* When the local git remote URL is unavailable (``None``/empty), corroboration
is impossible, so the guard does not block (best-effort only).
* Otherwise, the resolved ``org/repo`` slug must appear in the local remote URL
(case-insensitive); if it does not, the guard blocks.
1. Cross-Project Mutation Boundary (#707):
When ``for_mutation`` is True (codebase mutation operation: branch, commit, PR,
merge, branch deletion), the target repository (``resolved_org/resolved_repo``)
MUST match the primary authorized project context (``primary_org/primary_repo``
or parsed from ``local_remote_url``). Any attempt to mutate a different project
fails closed, even if explicit org/repo were passed. Metadata operations (such
as creating issues or commenting on issues) across projects remain allowed.
2. Workspace Mismatch Guard (#530):
When ``for_mutation`` is False (or targets match), bare remotes must resolve
to an org/repo slug present in the local git remote URL. When explicit org/repo
are supplied for non-mutation operations, the caller's intent is authoritative.
"""
reasons: list[str] = []
eff_primary_org = primary_org
eff_primary_repo = primary_repo
if not eff_primary_org or not eff_primary_repo:
parsed_primary = parse_org_repo_from_remote_url(local_remote_url)
if parsed_primary:
eff_primary_org = eff_primary_org or parsed_primary[0]
eff_primary_repo = eff_primary_repo or parsed_primary[1]
# #707: Enforce cross-project codebase mutation boundary if for_mutation is True
if for_mutation and eff_primary_org and eff_primary_repo:
if (
resolved_org.lower() != eff_primary_org.lower()
or resolved_repo.lower() != eff_primary_repo.lower()
):
reasons.append(
f"Cross-project mutation guard (#707): Attempted codebase mutation targeting "
f"'{resolved_org}/{resolved_repo}' outside of primary authorized project "
f"context '{eff_primary_org}/{eff_primary_repo}'"
)
return {
"proven": False,
"block": True,
"cross_project_mutation_block": True,
"reasons": reasons,
"remote": remote,
"resolved_org": resolved_org,
"resolved_repo": resolved_repo,
"primary_org": eff_primary_org,
"primary_repo": eff_primary_repo,
"local_remote_url": local_remote_url,
"remediation": f"Cross-project codebase work is forbidden. Create an issue in the target repository ('{resolved_org}/{resolved_repo}') instead.",
}
if org_explicit and repo_explicit:
return _assessment(True, reasons, remote, resolved_org, resolved_repo, local_remote_url)
@@ -88,6 +130,16 @@ def assess_remote_repo_match(
def format_remote_repo_guard_error(assessment: dict) -> str:
"""Single RuntimeError message for the MCP resolver gate."""
if assessment.get("cross_project_mutation_block"):
resolved = f"{assessment.get('resolved_org')}/{assessment.get('resolved_repo')}"
primary = f"{assessment.get('primary_org')}/{assessment.get('primary_repo')}"
return (
f"Cross-project mutation guard (#707): Attempted codebase mutation targeting '{resolved}' "
f"outside of primary authorized project context '{primary}'. "
f"Cross-project codebase work (creating branches, committing files, creating PRs) is forbidden; "
f"create an issue in the target repository ('{resolved}') instead."
)
reasons = "; ".join(
assessment.get("reasons") or ["remote/repo resolution mismatch"]
)
@@ -120,3 +172,4 @@ def _assessment(
"local_remote_url": local_remote_url,
"remediation": REMEDIATION,
}
@@ -0,0 +1,94 @@
import os
import sys
import unittest
from unittest import mock
import remote_repo_guard
import gitea_mcp_server as server
from session_context_binding import _reset_session_context_for_testing, bind_session_context
LOCAL_GITEA_TOOLS_URL = "https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
class TestCrossProjectMutationBoundary(unittest.TestCase):
def setUp(self):
os.environ["PYTEST_CURRENT_TEST"] = "test"
_reset_session_context_for_testing()
def tearDown(self):
_reset_session_context_for_testing()
os.environ.pop("PYTEST_CURRENT_TEST", None)
def test_cross_project_codebase_mutation_blocked(self):
"""Codebase mutations targeting another project must fail closed (#707)."""
assessment = remote_repo_guard.assess_remote_repo_match(
remote="prgs",
resolved_org="Other-Org",
resolved_repo="Other-Repo",
local_remote_url=LOCAL_GITEA_TOOLS_URL,
org_explicit=True,
repo_explicit=True,
for_mutation=True,
)
self.assertTrue(assessment["block"])
self.assertTrue(assessment.get("cross_project_mutation_block"))
self.assertEqual(assessment["primary_org"], "Scaled-Tech-Consulting")
self.assertEqual(assessment["primary_repo"], "Gitea-Tools")
err_msg = remote_repo_guard.format_remote_repo_guard_error(assessment)
self.assertIn("Cross-project mutation guard (#707)", err_msg)
self.assertIn("Attempted codebase mutation targeting 'Other-Org/Other-Repo'", err_msg)
self.assertIn("outside of primary authorized project context 'Scaled-Tech-Consulting/Gitea-Tools'", err_msg)
self.assertIn("create an issue in the target repository ('Other-Org/Other-Repo') instead", err_msg)
def test_same_project_codebase_mutation_allowed(self):
"""Codebase mutations targeting the primary project context are allowed."""
assessment = remote_repo_guard.assess_remote_repo_match(
remote="prgs",
resolved_org="Scaled-Tech-Consulting",
resolved_repo="Gitea-Tools",
local_remote_url=LOCAL_GITEA_TOOLS_URL,
org_explicit=True,
repo_explicit=True,
for_mutation=True,
)
self.assertFalse(assessment["block"])
self.assertFalse(assessment.get("cross_project_mutation_block", False))
def test_cross_project_metadata_operation_allowed(self):
"""Metadata operations (issue creation, comments) across project boundaries are allowed."""
assessment = remote_repo_guard.assess_remote_repo_match(
remote="prgs",
resolved_org="Other-Org",
resolved_repo="Other-Repo",
local_remote_url=LOCAL_GITEA_TOOLS_URL,
org_explicit=True,
repo_explicit=True,
for_mutation=False,
)
self.assertTrue(assessment["proven"])
self.assertFalse(assessment["block"])
@mock.patch.dict(os.environ, {"GITEA_FORCE_REMOTE_REPO_CHECK": "1"})
@mock.patch("gitea_mcp_server._local_git_remote_url", return_value=LOCAL_GITEA_TOOLS_URL)
def test_mcp_server_resolve_cross_project_mutation_fails_closed(self, mock_remote):
"""_resolve with for_mutation=True blocks cross-project targets."""
with self.assertRaises(RuntimeError) as ctx:
server._resolve("prgs", None, "Other-Org", "Other-Repo", for_mutation=True)
err = str(ctx.exception)
self.assertIn("Cross-project mutation guard (#707)", err)
self.assertIn("create an issue in the target repository", err)
@mock.patch.dict(os.environ, {"GITEA_FORCE_REMOTE_REPO_CHECK": "1"})
@mock.patch("gitea_mcp_server._local_git_remote_url", return_value=LOCAL_GITEA_TOOLS_URL)
def test_mcp_server_resolve_cross_project_metadata_succeeds(self, mock_remote):
"""_resolve with for_mutation=False allows explicit cross-project target."""
host, org, repo = server._resolve("prgs", None, "Other-Org", "Other-Repo", for_mutation=False)
self.assertEqual(org, "Other-Org")
self.assertEqual(repo, "Other-Repo")
if __name__ == "__main__":
unittest.main()
-190
View File
@@ -1,190 +0,0 @@
"""Tests for Sentry/GlitchTip observability console (#649, Phase 4)."""
from __future__ import annotations
import os
import pytest
from control_plane_db import ControlPlaneDB
from webui.app import create_app
from webui.console_authz import authorize, resolve_principal
from webui.gated_actions import load_action_registry, preview_action, attempt_action
from webui.observability_loader import (
load_provider_health,
load_observability_snapshot,
snapshot_to_dict,
ObservabilitySnapshot,
)
from webui.observability_views import render_observability_page
from tests.webui_testclient import TestClient
@pytest.fixture
def test_db(tmp_path):
db_path = str(tmp_path / "test_control_plane.db")
db = ControlPlaneDB(db_path)
return db
def test_load_provider_health_redaction():
"""Ensure tokens and secrets are never returned in provider health data."""
env = {
"SENTRY_BASE_URL": "https://sentry.prgs.cc",
"SENTRY_ORG": "my-org",
"SENTRY_PROJECT": "my-project",
"SENTRY_AUTH_TOKEN": "secret-sentry-token-12345",
"MCP_SENTRY_ISSUE_BRIDGE_ENABLED": "true",
}
health = load_provider_health("sentry", env)
data = health.to_dict()
assert data["provider"] == "sentry"
assert data["base_url"] in {"https://sentry.prgs.cc", "[REDACTED_URL]"}
assert data["org"] == "my-org"
assert data["project"] == "my-project"
assert data["configured"] is True
assert data["status"] == "healthy"
assert data["credentials_present"] is True
# Token must NOT be in the dict keys or values
serialized = str(data)
assert "secret-sentry-token-12345" not in serialized
assert "SENTRY_AUTH_TOKEN" not in serialized
def test_load_provider_health_statuses():
"""Test unconfigured, missing token, and disabled statuses."""
# Not configured
h1 = load_provider_health("sentry", {})
d1 = h1.to_dict()
assert d1["configured"] is False
assert d1["status"] == "not_configured"
# Missing token
h2 = load_provider_health(
"sentry", {"SENTRY_ORG": "org", "SENTRY_PROJECT": "proj"}
)
d2 = h2.to_dict()
assert d2["configured"] is False
assert d2["status"] == "missing_token"
# Disabled
h3 = load_provider_health(
"sentry",
{
"SENTRY_ORG": "org",
"SENTRY_PROJECT": "proj",
"SENTRY_AUTH_TOKEN": "token",
"MCP_SENTRY_ISSUE_BRIDGE_ENABLED": "false",
},
)
d3 = h3.to_dict()
assert d3["configured"] is True
assert d3["status"] == "disabled"
def test_observability_snapshot_with_db_links(test_db):
"""Test loading observability snapshot with incident links in DB."""
test_db.upsert_incident_link(
provider="sentry",
provider_issue_id="101",
gitea_org="Scaled-Tech-Consulting",
gitea_repo="Gitea-Tools",
gitea_issue_number=649,
provider_base_url="https://sentry.prgs.cc",
provider_org="Scaled-Tech-Consulting",
provider_project="Gitea-Tools",
provider_short_id="ST-101",
provider_permalink="https://sentry.prgs.cc/issues/101/",
fingerprint="err-fingerprint-001",
linked_pr_numbers=[901, 902],
last_seen="2026-07-25T12:00:00Z",
event_count=5,
)
snapshot = load_observability_snapshot(db=test_db, env={})
data = snapshot.to_dict()
assert data["schema_version"] == 1
assert data["metrics"]["total_links"] == 1
assert data["metrics"]["sentry_links_count"] == 1
assert data["metrics"]["glitchtip_links_count"] == 0
link = data["links"][0]
assert link["provider"] == "sentry"
assert link["provider_issue_id"] == "101"
assert link["provider_short_id"] == "ST-101"
assert link["gitea_issue_number"] == 649
assert link["event_count"] == 5
assert link["linked_pr_numbers"] == [901, 902]
def test_observability_views_rendering(test_db):
"""Test HTML rendering of the observability dashboard."""
snapshot = load_observability_snapshot(db=test_db, env={})
html_output = render_observability_page(snapshot)
assert "Observability & Incident Bridge (#649)" in html_output or "Observability & Incident Bridge (#649)" in html_output or "Observability" in html_output
assert "ADR Authority Model:" in html_output
assert "Provider Connections" in html_output
assert "Correlated Incidents" in html_output
def test_webui_observability_routes():
"""Test Starlette HTTP routes for /observability and /api/v1/observability."""
client = TestClient(create_app())
# HTML page route
res_html = client.get("/observability")
assert res_html.status_code == 200
assert "text/html" in res_html.headers["content-type"]
assert "Observability" in res_html.text
# Versioned API route
res_api_v1 = client.get("/api/v1/observability")
assert res_api_v1.status_code == 200
assert "application/json" in res_api_v1.headers["content-type"]
data_v1 = res_api_v1.json()
assert "schema_version" in data_v1
assert "providers" in data_v1
assert "links" in data_v1
assert "metrics" in data_v1
# Compatibility alias route
res_api_alias = client.get("/api/observability")
assert res_api_alias.status_code == 200
assert res_api_alias.json() == data_v1
def test_observability_gated_actions():
"""Ensure observability actions are registered, gated, and fail closed in MVP mode."""
registry = load_action_registry()
action_reconcile = registry.get("observability_reconcile_incident")
assert action_reconcile is not None
assert action_reconcile.task_key == "observability_reconcile_incident"
assert action_reconcile.mcp_tool == "gitea_observability_reconcile_incident"
action_link = registry.get("observability_link_issue")
assert action_link is not None
assert action_link.task_key == "observability_link_issue"
# Preview returns mutation ledger
prev = preview_action("observability_reconcile_incident", provider="sentry", issue_id="101")
assert prev["action_id"] == "observability_reconcile_incident"
assert prev["enabled"] is False
# Execution fails closed in MVP mode
att = attempt_action("observability_reconcile_incident", provider="sentry", issue_id="101")
assert att["success"] is False
assert att["error"] == "action_disabled"
def test_observability_authz_rbac():
"""Test RBAC authorization for observability actions."""
principal = resolve_principal({})
# Check authorize decision
decision = authorize("observability_reconcile_incident", principal)
assert decision.action_id == "observability_reconcile_incident"
# Phase 4 action denies in Phase 1 runtime by default
assert decision.allowed is False
-21
View File
@@ -87,11 +87,6 @@ from webui.notifications import (
from webui.notification_views import render_notifications_page
from webui import request_service
from webui.request_views import render_requests_page
from webui.observability_loader import (
load_observability_snapshot,
snapshot_to_dict as observability_snapshot_to_dict,
)
from webui.observability_views import render_observability_page
_READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
_AUDIT_MUTATION_PATHS = frozenset({"/audit", "/api/audit"})
@@ -915,19 +910,6 @@ async def api_notifications(request: Request) -> JSONResponse:
data = notifications_snapshot_to_dict(snap)
return JSONResponse(data)
async def observability_route(request: Request) -> HTMLResponse:
snap = load_observability_snapshot()
html_content = render_observability_page(snap)
return HTMLResponse(html_content)
async def api_observability(request: Request) -> JSONResponse:
snap = load_observability_snapshot()
data = observability_snapshot_to_dict(snap)
return JSONResponse(data)
def _default_request_scope() -> dict[str, str]:
"""Resolve remote/org/repo from the project registry for request forms.
@@ -1093,9 +1075,6 @@ def create_app(*, bind_host: str | None = None) -> Starlette:
Route("/api/analytics", api_v1_analytics, methods=["GET"]),
Route("/api/v1/analytics", api_v1_analytics, methods=["GET"]),
Route("/api/v1/analytics/usage", api_v1_analytics_ingest, methods=["POST"]),
Route("/observability", observability_route, methods=["GET"]),
Route("/api/observability", api_observability, methods=["GET"]),
Route("/api/v1/observability", api_observability, methods=["GET"]),
Route("/audit", audit, methods=["GET", "POST"]),
Route("/api/audit", api_audit, methods=["GET", "POST"]),
Route("/worktrees", worktrees, methods=["GET"]),
-23
View File
@@ -317,29 +317,6 @@ _ACTION_SPECS: tuple[ConsoleAction, ...] = (
phase=2,
summary="Run reconciler cleanup for merged or superseded PR branches.",
),
# #649: Phase 4 observability & incident bridge actions.
ConsoleAction(
action_id="observability_reconcile_incident",
task_key="observability_reconcile_incident",
action_class=CLASS_WRITE,
minimum_role=OPERATOR,
requires_confirmation=True,
dual_control=False,
break_glass=False,
phase=4,
summary="Trigger/reconcile durable Gitea issue creation from a provider incident.",
),
ConsoleAction(
action_id="observability_link_issue",
task_key="observability_link_issue",
action_class=CLASS_WRITE,
minimum_role=OPERATOR,
requires_confirmation=True,
dual_control=False,
break_glass=False,
phase=4,
summary="Link a provider incident to an existing Gitea issue.",
),
# #643: submit a work request — desired role, issue/PR, intent — and let
# the allocator reserve it. This is the one Phase 2 action whose execution
# path is actually implemented (``webui.request_service``), so it carries
-5
View File
@@ -185,11 +185,6 @@ def build_action_registry() -> ActionRegistry:
"console.rebind_session_worktree", "Rebind session worktree to verified lease."),
("system.reconcile_cleanups", "Reconcile cleanups", "reconcile_cleanups",
"console.reconcile_cleanups", "Run reconciler cleanup for merged or superseded PRs."),
# #649: Phase 4 observability & incident bridge actions.
("observability_reconcile_incident", "Reconcile incident", "observability_reconcile_incident",
"gitea_observability_reconcile_incident", "Trigger or dry-run durable issue reconciliation for a provider incident."),
("observability_link_issue", "Link incident issue", "observability_link_issue",
"gitea_observability_link_issue", "Link a provider incident to a Gitea tracking issue."),
)
actions = tuple(
GatedAction(
-1
View File
@@ -73,7 +73,6 @@ NAV_GROUPS: tuple[NavGroup, ...] = (
)),
NavGroup("Insights", (
NavItem("/insights", "Insights", "stub"),
NavItem("/observability", "Observability"),
NavItem("/analytics", "Analytics"),
NavItem("/audit", "Audit"),
)),
-275
View File
@@ -1,275 +0,0 @@
"""Sentry/GlitchTip observability and incident correlation loader for the console (#649, Phase 4).
Operators need to inspect provider connection status (Sentry/GlitchTip), error
correlations, and durable Gitea issue linkage without treating raw incidents
as allocator work.
ADR authority model:
* Gitea owns work.
* Providers (Sentry/GlitchTip) observe incidents.
* Control-plane DB coordinates incident links.
* The #612 bridge reconciles observations into durable Gitea issues.
* The web console projects read-only state and gates mutations.
Redaction boundary:
* Provider auth tokens, DSNs, Authorization headers, and sensitive local file
paths are ALWAYS redacted before leaving this module.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Any
from control_plane_db import ControlPlaneDB
import sentry_incident_bridge
from webui import console_redaction
OBSERVABILITY_SCHEMA_VERSION = 1
@dataclass(frozen=True)
class ProviderHealth:
"""Connection and health status of an observability provider."""
provider: str
base_url: str
org: str
project: str
configured: bool
status: str
bridge_enabled: bool
lookback: str
min_events_for_issue: int
self_hosted: bool
environment: str | None = None
credentials_present: bool = False
def to_dict(self) -> dict[str, Any]:
data = {
"provider": self.provider,
"base_url": self.base_url,
"org": self.org,
"project": self.project,
"configured": self.configured,
"status": self.status,
"bridge_enabled": self.bridge_enabled,
"lookback": self.lookback,
"min_events_for_issue": self.min_events_for_issue,
"self_hosted": self.self_hosted,
"environment": self.environment,
"credentials_present": self.credentials_present,
}
return console_redaction.redact_payload(data)
@dataclass(frozen=True)
class CorrelatedIncidentLink:
"""One linked provider incident ↔ Gitea issue correlation record."""
link_id: int
provider: str
provider_base_url: str
provider_org: str
provider_project: str
provider_issue_id: str
provider_short_id: str | None
provider_permalink: str | None
fingerprint: str | None
gitea_org: str
gitea_repo: str
gitea_issue_number: int
linked_pr_numbers: list[int]
last_seen: str | None
event_count: int
created_at: str | None
updated_at: str | None
def to_dict(self) -> dict[str, Any]:
data = {
"link_id": self.link_id,
"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,
"gitea_org": self.gitea_org,
"gitea_repo": self.gitea_repo,
"gitea_issue_number": self.gitea_issue_number,
"linked_pr_numbers": self.linked_pr_numbers,
"last_seen": self.last_seen,
"event_count": self.event_count,
"created_at": self.created_at,
"updated_at": self.updated_at,
}
return console_redaction.redact_payload(data)
@dataclass(frozen=True)
class ObservabilitySnapshot:
"""Read-only snapshot of observability provider status and incident correlations."""
schema_version: int
providers: list[ProviderHealth]
links: list[CorrelatedIncidentLink]
total_links: int
sentry_links_count: int
glitchtip_links_count: int
bridge_active: bool
def to_dict(self) -> dict[str, Any]:
return {
"schema_version": self.schema_version,
"providers": [p.to_dict() for p in self.providers],
"links": [link.to_dict() for link in self.links],
"metrics": {
"total_links": self.total_links,
"sentry_links_count": self.sentry_links_count,
"glitchtip_links_count": self.glitchtip_links_count,
"bridge_active": self.bridge_active,
},
}
def load_provider_health(
provider_name: str = "sentry",
env: dict[str, str] | None = None,
) -> ProviderHealth:
"""Inspect configuration and connection health for an observability provider."""
source_env = dict(env if env is not None else os.environ)
if provider_name.lower() == "sentry":
config = sentry_incident_bridge.load_bridge_config(source_env)
token = sentry_incident_bridge.resolve_token(source_env)
has_token = bool(token)
configured = bool(config.org and config.project and has_token)
if not config.org or not config.project:
status = "not_configured"
elif not has_token:
status = "missing_token"
elif not config.bridge_enabled:
status = "disabled"
else:
status = "healthy"
return ProviderHealth(
provider="sentry",
base_url=config.base_url,
org=config.org or "unconfigured",
project=config.project or "unconfigured",
configured=configured,
status=status,
bridge_enabled=config.bridge_enabled,
lookback=config.lookback,
min_events_for_issue=config.min_events_for_issue,
self_hosted=not config.base_url.rstrip("/").endswith("sentry.io"),
environment=config.environment,
credentials_present=has_token,
)
# GlitchTip or fallback provider configuration
glitchtip_url = (source_env.get("GLITCHTIP_BASE_URL") or "https://glitchtip.prgs.cc").strip()
glitchtip_org = (source_env.get("GLITCHTIP_ORG") or "").strip()
glitchtip_proj = (source_env.get("GLITCHTIP_PROJECT") or "").strip()
glitchtip_token = (source_env.get("GLITCHTIP_AUTH_TOKEN") or "").strip()
has_token = bool(glitchtip_token)
configured = bool(glitchtip_org and glitchtip_proj and has_token)
status = "healthy" if configured else ("missing_token" if glitchtip_org and glitchtip_proj else "not_configured")
return ProviderHealth(
provider="glitchtip",
base_url=glitchtip_url,
org=glitchtip_org or "unconfigured",
project=glitchtip_proj or "unconfigured",
configured=configured,
status=status,
bridge_enabled=configured,
lookback="24h",
min_events_for_issue=2,
self_hosted=True,
environment=source_env.get("GLITCHTIP_ENVIRONMENT"),
credentials_present=has_token,
)
def _parse_pr_numbers(raw: Any) -> list[int]:
if isinstance(raw, list):
return [int(x) for x in raw if str(x).isdigit()]
if isinstance(raw, str) and raw.strip():
import json
try:
parsed = json.loads(raw)
if isinstance(parsed, list):
return [int(x) for x in parsed if str(x).isdigit()]
except Exception:
pass
return []
def load_observability_snapshot(
db: ControlPlaneDB | None = None,
env: dict[str, str] | None = None,
) -> ObservabilitySnapshot:
"""Build a read-only snapshot of observability connection health and incident links."""
sentry_health = load_provider_health("sentry", env)
glitchtip_health = load_provider_health("glitchtip", env)
providers = [sentry_health, glitchtip_health]
target_db = db or ControlPlaneDB()
raw_links = target_db.list_incident_links(limit=100)
links: list[CorrelatedIncidentLink] = []
sentry_cnt = 0
glitchtip_cnt = 0
for r in raw_links:
prov = (r.get("provider") or "sentry").lower()
if prov == "sentry":
sentry_cnt += 1
elif prov == "glitchtip":
glitchtip_cnt += 1
pr_nums = _parse_pr_numbers(r.get("linked_pr_numbers"))
links.append(
CorrelatedIncidentLink(
link_id=int(r.get("link_id", 0)),
provider=prov,
provider_base_url=r.get("provider_base_url") or "",
provider_org=r.get("provider_org") or "",
provider_project=r.get("provider_project") or "",
provider_issue_id=str(r.get("provider_issue_id") or ""),
provider_short_id=r.get("provider_short_id"),
provider_permalink=r.get("provider_permalink"),
fingerprint=r.get("fingerprint"),
gitea_org=r.get("gitea_org") or "Scaled-Tech-Consulting",
gitea_repo=r.get("gitea_repo") or "Gitea-Tools",
gitea_issue_number=int(r.get("gitea_issue_number", 0)),
linked_pr_numbers=pr_nums,
last_seen=r.get("last_seen"),
event_count=int(r.get("event_count", 1)),
created_at=r.get("created_at"),
updated_at=r.get("updated_at"),
)
)
bridge_active = any(p.bridge_enabled for p in providers)
return ObservabilitySnapshot(
schema_version=OBSERVABILITY_SCHEMA_VERSION,
providers=providers,
links=links,
total_links=len(links),
sentry_links_count=sentry_cnt,
glitchtip_links_count=glitchtip_cnt,
bridge_active=bridge_active,
)
def snapshot_to_dict(snapshot: ObservabilitySnapshot) -> dict[str, Any]:
return snapshot.to_dict()
-143
View File
@@ -1,143 +0,0 @@
"""HTML view renderer for the Sentry/GlitchTip observability console (#649, Phase 4).
Renders connection status widgets, error correlation links, and gated issue creation
affordances over the read-only observability snapshot.
"""
from __future__ import annotations
import html
from typing import Any
from webui.layout import render_page
from webui.observability_loader import ObservabilitySnapshot, snapshot_to_dict
def _badge(status: str) -> str:
st = (status or "").lower()
if st == "healthy":
return '<span class="badge badge-success">healthy</span>'
if st == "disabled":
return '<span class="badge badge-warning">disabled (dry-run)</span>'
if st in {"missing_token", "not_configured"}:
return f'<span class="badge badge-muted">{html.escape(st)}</span>'
return f'<span class="badge">{html.escape(st)}</span>'
def _provider_card(p: dict[str, Any]) -> str:
name = html.escape(str(p.get("provider", "provider")).upper())
base_url = html.escape(str(p.get("base_url", "")))
org = html.escape(str(p.get("org", "")))
proj = html.escape(str(p.get("project", "")))
status_badge = _badge(str(p.get("status", "")))
min_events = p.get("min_events_for_issue", 2)
lookback = html.escape(str(p.get("lookback", "24h")))
bridge_enabled = "yes" if p.get("bridge_enabled") else "no"
return f"""
<div class="card" style="margin-bottom: 1rem; padding: 1rem; border: 1px solid #ccc; border-radius: 6px;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<h3 style="margin: 0;">{name} Connection</h3>
<div>{status_badge}</div>
</div>
<table style="width: 100%; margin-top: 0.5rem; border-collapse: collapse;">
<tr><td><strong>Base URL:</strong></td><td><code>{base_url}</code></td></tr>
<tr><td><strong>Scope:</strong></td><td><code>{org} / {proj}</code></td></tr>
<tr><td><strong>Bridge Enabled:</strong></td><td><code>{bridge_enabled}</code></td></tr>
<tr><td><strong>Min Events for Issue:</strong></td><td><code>{min_events}</code></td></tr>
<tr><td><strong>Lookback Window:</strong></td><td><code>{lookback}</code></td></tr>
</table>
</div>
"""
def render_observability_page(snapshot: ObservabilitySnapshot | dict[str, Any]) -> str:
"""Render the observability dashboard HTML page."""
data = snapshot.to_dict() if isinstance(snapshot, ObservabilitySnapshot) else dict(snapshot)
providers_raw = data.get("providers", [])
provider_cards = "".join(_provider_card(p) for p in providers_raw) if providers_raw else "<p>No providers configured.</p>"
links = data.get("links", [])
link_rows = []
for l in links:
prov = html.escape(str(l.get("provider", "")))
p_issue_id = html.escape(str(l.get("provider_issue_id", "")))
fingerprint = html.escape(str(l.get("fingerprint") or ""))
g_issue_num = int(l.get("gitea_issue_number", 0))
g_org = html.escape(str(l.get("gitea_org", "")))
g_repo = html.escape(str(l.get("gitea_repo", "")))
g_issue_link = f'<strong>#{g_issue_num}</strong> ({g_org}/{g_repo})'
event_cnt = int(l.get("event_count", 1))
last_seen = html.escape(str(l.get("last_seen") or ""))
short_id = html.escape(str(l.get("provider_short_id") or p_issue_id))
link_rows.append(f"""
<tr>
<td><code>{prov}</code></td>
<td><strong>{short_id}</strong><br><small style="color: #666;">id: {p_issue_id}</small></td>
<td><code>{fingerprint}</code></td>
<td>{g_issue_link}</td>
<td>{event_cnt}</td>
<td><small>{last_seen}</small></td>
</tr>
""")
table_body = "".join(link_rows) if link_rows else '<tr><td colspan="6" style="text-align: center; padding: 1.5rem; color: #666;">No correlated incident links stored. Bridge operates under dry-run default.</td></tr>'
metrics = data.get("metrics", {})
total_links = metrics.get("total_links", 0)
sentry_cnt = metrics.get("sentry_links_count", 0)
glitchtip_cnt = metrics.get("glitchtip_links_count", 0)
body_html = f"""
<h2>Observability & Incident Bridge (#649)</h2>
<p>Read-only console surface for Sentry/GlitchTip provider connections, error correlation,
and durable Gitea issue linkage.</p>
<div class="alert alert-info" style="background: #f0f4f8; padding: 1rem; border-left: 4px solid #0052cc; margin-bottom: 1.5rem;">
<strong>ADR Authority Model:</strong> Gitea records durable issue history. Control-plane DB coordinates incident links.
Sentry/GlitchTip observe errors. Raw monitoring incidents are <em>never</em> assignable control-plane work items.
Durable issue creation is gated and dry-runable via the <code>#612</code> bridge APIs.
</div>
<h3>Provider Connections</h3>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1rem; margin-bottom: 2rem;">
{provider_cards}
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3 style="margin: 0;">Correlated Incidents ({total_links})</h3>
<div>
<span class="badge" style="margin-right: 0.5rem;">Sentry: {sentry_cnt}</span>
<span class="badge">GlitchTip: {glitchtip_cnt}</span>
</div>
</div>
<table class="table" style="width: 100%; border-collapse: collapse; border: 1px solid #ddd;">
<thead>
<tr style="background: #f9f9f9; text-align: left;">
<th style="padding: 0.5rem; border-bottom: 2px solid #ddd;">Provider</th>
<th style="padding: 0.5rem; border-bottom: 2px solid #ddd;">Incident ID</th>
<th style="padding: 0.5rem; border-bottom: 2px solid #ddd;">Fingerprint</th>
<th style="padding: 0.5rem; border-bottom: 2px solid #ddd;">Gitea Issue Link</th>
<th style="padding: 0.5rem; border-bottom: 2px solid #ddd;">Events</th>
<th style="padding: 0.5rem; border-bottom: 2px solid #ddd;">Last Seen</th>
</tr>
</thead>
<tbody>
{table_body}
</tbody>
</table>
<div style="margin-top: 2rem; padding: 1rem; background: #fafafa; border: 1px solid #eee; border-radius: 4px;">
<h4 style="margin-top: 0;">Reconcile & Link Controls (Gated)</h4>
<p style="margin-bottom: 0.5rem; color: #555;">
Create or reconcile durable Gitea issues from provider observations using the <code>#612</code> incident bridge:
</p>
<code>mcp call gitea_observability_reconcile_incident --provider sentry --apply false</code>
</div>
"""
return render_page(title="Observability", body_html=body_html)