Compare commits

..
16 changed files with 722 additions and 506 deletions
+26
View File
@@ -1568,6 +1568,32 @@ class ControlPlaneDB:
).fetchone() ).fetchone()
return dict(row) if row else None 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) ──────────────────────────────────────────── # ── lease lifecycle (#601) ────────────────────────────────────────────
-41
View File
@@ -589,47 +589,6 @@ When dynamic profile switching is enabled and a profile is activated via `gitea_
2. Call `gitea_whoami` with the target remote to prove and verify the fresh Gitea authenticated identity. 2. Call `gitea_whoami` with the target remote to prove and verify the fresh Gitea authenticated identity.
This guarantees the active profile operations align with the actual Gitea authenticated user credential. This guarantees the active profile operations align with the actual Gitea authenticated user credential.
### 4. Review-State Invalidation on Profile Switch (#690)
A cross-profile activation (e.g. reviewer → author → reviewer) is a session
boundary for formal review state. On any switch where the activated profile
differs from the previous one, `gitea_activate_profile` invalidates, in
memory **and** in durable session state (for both the old and new profile
identities):
- preflight identity/capability stamps (`gitea_whoami` / `gitea_resolve_task_capability` proof),
- review workflow-load proof (`gitea_load_review_workflow`),
- the review decision lock (including `final_review_decision_ready` markers),
- the reviewer PR session lease binding,
- live namespace-health assessments.
Before any formal verdict (`gitea_mark_final_review_decision` /
`gitea_submit_pr_review`) the full reviewer preflight must be re-established
under the new profile: `gitea_whoami`, `gitea_load_review_workflow`,
`gitea_resolve_task_capability(review_pr)`, live head re-pin, and lease
re-acquire/adopt. Switching back to the earlier profile does **not**
resurrect the prior run — durable state keyed by either profile identity is
cleared at switch time.
The supported pattern remains **separate session/namespace per role**
(dual-namespace, §2): file author-side follow-ups from an author session,
not by hopping profiles inside a formal review run. Runtime profile
switching is the operator-approved exception and always carries the
re-preflight cost above.
### 5. Namespace Provenance (#690)
The server cannot derive its own client-managed MCP namespace name, so a
launcher may declare it via the `GITEA_MCP_NAMESPACE` environment variable
(e.g. `gitea-reviewer`). `gitea_whoami`, `gitea_get_runtime_context`, and
`gitea_resolve_task_capability` report `namespace_provenance` — the
configured client namespace, the active execution profile, and, for tasks
with a required namespace (`review_pr` → `gitea-reviewer`, `merge_pr` →
`gitea-merger`), a mismatch verdict. A declared namespace that disagrees
with the requested task's required namespace **fails closed**. An
undeclared namespace is reported as `unknown` and is never treated as
proof either way.
## Gitea MCP Runtime Isolation and Worktree Safety ## Gitea MCP Runtime Isolation and Worktree Safety
To ensure high availability and prevent broken feature worktrees from disabling essential security/identity controls, the Gitea MCP server implements runtime isolation: To ensure high availability and prevent broken feature worktrees from disabling essential security/identity controls, the Gitea MCP server implements runtime isolation:
-21
View File
@@ -86,24 +86,3 @@ When a namespace returns EOF, follow
When blocked, repair the IDE namespace and re-record a healthy When blocked, repair the IDE namespace and re-record a healthy
`client_namespace` assessment before retrying the mutation. `client_namespace` assessment before retrying the mutation.
## Namespace provenance (#690)
A server process cannot derive the name of the client-managed namespace it is
registered under, so the launcher may declare it with the
`GITEA_MCP_NAMESPACE` environment variable (e.g. `GITEA_MCP_NAMESPACE=gitea-reviewer`).
- `gitea_whoami`, `gitea_get_runtime_context`, and
`gitea_resolve_task_capability` report `namespace_provenance`: the declared
client namespace, the active execution profile, and — for tasks with a
required namespace (`review_pr`/`submit_review``gitea-reviewer`,
`merge_pr``gitea-merger`) — a `mismatch` verdict.
- A declared namespace that disagrees with the requested task's required
namespace **fails closed** (`allowed_in_current_session=false` with a STOP
guidance entry).
- An undeclared namespace is reported as `namespace_source="unknown"` and is
never treated as proof either way.
- A profile switch via `gitea_activate_profile` clears all recorded live
namespace-health assessments; re-probe through the client before further
review/merge mutations.
@@ -0,0 +1,35 @@
# 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,6 +98,8 @@ already define, and a regression test asserts each mapping matches.
| `system.rebind_session_worktree` | operator | gated_write | `gitea.read` | Yes | No | No | 2 | | `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 | | `system.reconcile_cleanups` | controller | privileged | `gitea.pr.close` | Yes | No | No | 2 |
| `initiate_workflow` | operator | gated_write | `gitea.read` | 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 **Dual control** means the acting principal may not be the sole authority: a
second distinct principal must confirm. **Break-glass** means the action is second distinct principal must confirm. **Break-glass** means the action is
+1 -119
View File
@@ -839,75 +839,6 @@ def _invalidate_preflight_identity_state() -> None:
_clear_preflight_capability_state() _clear_preflight_capability_state()
# #690: session-boundary invalidation record for the most recent cross-profile
# activation. Surfaced in runtime diagnostics so a formal review run can prove
# its state was reset by a profile switch and must be fully re-established.
_PROFILE_SWITCH_INVALIDATION: dict | None = None
def _invalidate_review_state_on_profile_switch(
before_profile: str,
after_profile: str,
) -> dict:
"""Invalidate review-critical session state on a profile switch (#690).
Workflow-load proof, reviewer lease binding, the review decision lock,
live namespace health, and preflight identity/capability stamps recorded
under the prior profile are contaminated for the new role. Durable state
keyed by *either* profile identity is cleared so a reviewer author
reviewer hop cannot resurrect a stale review run: the full reviewer
preflight (gitea_whoami, gitea_load_review_workflow,
gitea_resolve_task_capability(review_pr), live head re-pin, lease
re-acquire/adopt) must be re-established before any formal verdict.
"""
global _PROFILE_SWITCH_INVALIDATION
invalidated: list[str] = []
_invalidate_preflight_identity_state()
invalidated.append("preflight_identity_capability")
review_workflow_load.clear_review_workflow_load()
invalidated.append("review_workflow_load")
_save_review_decision_lock(None)
invalidated.append("review_decision_lock")
reviewer_pr_lease.clear_session_lease()
invalidated.append("reviewer_session_lease")
if _LIVE_NAMESPACE_HEALTH:
_LIVE_NAMESPACE_HEALTH.clear()
invalidated.append("live_namespace_health")
# Durable records keyed by either profile identity must not survive the
# switch, or activating author → reviewer → author could revive a stale
# review run without re-preflight.
for identity in {before_profile, after_profile}:
if not identity:
continue
try:
mcp_session_state.clear_state(
kind=mcp_session_state.KIND_DECISION_LOCK,
profile_identity=identity,
)
mcp_session_state.clear_state(
kind=mcp_session_state.KIND_WORKFLOW_LOAD,
profile_identity=identity,
)
except Exception:
pass # best-effort durable cleanup; in-memory state already reset
invalidated.append("durable_profile_state")
_PROFILE_SWITCH_INVALIDATION = {
"from_profile": before_profile,
"to_profile": after_profile,
"invalidated": invalidated,
"invalidated_at": datetime.now(timezone.utc).isoformat(),
"re_preflight_required": True,
}
return dict(_PROFILE_SWITCH_INVALIDATION)
def record_preflight_check( def record_preflight_check(
type_name: str, type_name: str,
resolved_role: str | None = None, resolved_role: str | None = None,
@@ -17279,11 +17210,6 @@ def gitea_whoami(
"session_context_audit": session_ctx.mutation_context_audit_fields(), "session_context_audit": session_ctx.mutation_context_audit_fields(),
"identity_match": not id_match.get("block"), "identity_match": not id_match.get("block"),
"identity_match_reasons": id_match.get("reasons") or [], "identity_match_reasons": id_match.get("reasons") or [],
# #690 AC4: report launcher-declared client namespace alongside the
# active execution profile so drift is visible in diagnostics.
"namespace_provenance": mcp_namespace_health.namespace_provenance(
active_profile=profile["profile_name"]
),
} }
if id_match.get("block"): if id_match.get("block"):
_invalidate_preflight_identity_state() _invalidate_preflight_identity_state()
@@ -18182,11 +18108,6 @@ def gitea_get_runtime_context(
"shell_health": native_mcp_preference.shell_health_status(), "shell_health": native_mcp_preference.shell_health_status(),
"workflow_load_proof": review_workflow_load.workflow_load_status( "workflow_load_proof": review_workflow_load.workflow_load_status(
PROJECT_ROOT), PROJECT_ROOT),
# #690: namespace provenance + profile-switch invalidation evidence.
"namespace_provenance": mcp_namespace_health.namespace_provenance(
active_profile=profile["profile_name"]
),
"profile_switch_invalidation": _PROFILE_SWITCH_INVALIDATION,
} }
# #702: read-only visibility into the inherited GITEA_ACTIVE_WORKTREE # #702: read-only visibility into the inherited GITEA_ACTIVE_WORKTREE
@@ -18690,17 +18611,6 @@ def gitea_activate_profile(
source="gitea_activate_profile", source="gitea_activate_profile",
) )
# 4.7 #690: a profile switch is a session-boundary event for review state.
# Any workflow-load proof, reviewer lease, decision lock, namespace
# health, or preflight stamp recorded under the prior profile is
# contaminated for the new role and must be re-established under the new
# profile before any formal review verdict.
switch_invalidation = None
if before_profile != after_profile:
switch_invalidation = _invalidate_review_state_on_profile_switch(
before_profile, after_profile
)
# 5. Audit the switch if auditing is on # 5. Audit the switch if auditing is on
_audit( _audit(
"activate_profile", "activate_profile",
@@ -18711,12 +18621,11 @@ def gitea_activate_profile(
"before": before_profile, "before": before_profile,
"after": after_profile, "after": after_profile,
"session_context": session_ctx.mutation_context_audit_fields(), "session_context": session_ctx.mutation_context_audit_fields(),
"review_state_invalidated": bool(switch_invalidation),
}, },
username=after_identity, username=after_identity,
) )
result = { return {
"success": True, "success": True,
"message": f"Successfully activated profile '{profile_name}' (fresh identity verification complete).", "message": f"Successfully activated profile '{profile_name}' (fresh identity verification complete).",
"before_profile": before_profile, "before_profile": before_profile,
@@ -18726,18 +18635,6 @@ def gitea_activate_profile(
"session_context_audit": session_ctx.mutation_context_audit_fields(), "session_context_audit": session_ctx.mutation_context_audit_fields(),
"auto_profile_substitution": False, "auto_profile_substitution": False,
} }
if switch_invalidation is not None:
result["review_state_invalidation"] = switch_invalidation
result["re_preflight_required"] = True
result["exact_next_action"] = (
"Profile switch invalidated workflow-load proof, reviewer lease, "
"decision lock, and preflight stamps (#690). Before any formal "
"review verdict, re-run the full reviewer preflight: "
"gitea_whoami, gitea_load_review_workflow, "
"gitea_resolve_task_capability(review_pr), live head re-pin, and "
"lease re-acquire/adopt."
)
return result
@mcp.tool() @mcp.tool()
@@ -20982,22 +20879,12 @@ def gitea_resolve_task_capability(
f"{required_role} task '{task}' even if nearby permissions are " f"{required_role} task '{task}' even if nearby permissions are "
"present (fail closed)." "present (fail closed)."
) )
# #690 AC4: when the launcher declares a client namespace, a task with a
# required namespace must fail closed on mismatch (e.g. review_pr served
# from an author namespace).
ns_provenance = mcp_namespace_health.namespace_provenance(
task=task_key, active_profile=profile.get("profile_name")
)
ns_mismatch_reason = None
if ns_provenance.get("mismatch"):
ns_mismatch_reason = "; ".join(ns_provenance.get("reasons") or [])
cross_host_block = bool(remote_assess.get("block")) cross_host_block = bool(remote_assess.get("block"))
identity_block = bool(id_assess.get("block")) identity_block = bool(id_assess.get("block"))
drift_block = bool(ctx_assess.get("block")) drift_block = bool(ctx_assess.get("block"))
allowed_in_current_session = ( allowed_in_current_session = (
permission_allowed_in_current_session permission_allowed_in_current_session
and role_matches_current_session and role_matches_current_session
and not ns_provenance.get("mismatch")
and not cross_host_block and not cross_host_block
and not identity_block and not identity_block
and not drift_block and not drift_block
@@ -21048,8 +20935,6 @@ def gitea_resolve_task_capability(
) )
if role_mismatch_reason: if role_mismatch_reason:
deny_parts.append(role_mismatch_reason) deny_parts.append(role_mismatch_reason)
if ns_mismatch_reason:
deny_parts.append(ns_mismatch_reason)
if deny_parts: if deny_parts:
reason_msg = "; ".join(deny_parts) reason_msg = "; ".join(deny_parts)
elif configured and switching: elif configured and switching:
@@ -21127,8 +21012,6 @@ def gitea_resolve_task_capability(
task_role_guidance = [] task_role_guidance = []
if role_mismatch_reason: if role_mismatch_reason:
task_role_guidance.append(f"STOP: {role_mismatch_reason}") task_role_guidance.append(f"STOP: {role_mismatch_reason}")
if ns_mismatch_reason:
task_role_guidance.append(f"STOP: {ns_mismatch_reason}")
if required_role == "reviewer": if required_role == "reviewer":
if allowed_in_current_session: if allowed_in_current_session:
task_role_guidance.append( task_role_guidance.append(
@@ -21195,7 +21078,6 @@ def gitea_resolve_task_capability(
"session_context_audit": session_ctx.mutation_context_audit_fields(), "session_context_audit": session_ctx.mutation_context_audit_fields(),
"profile_remote_compatible": not cross_host_block, "profile_remote_compatible": not cross_host_block,
"identity_match": not identity_block, "identity_match": not identity_block,
"namespace_provenance": ns_provenance,
"auto_profile_substitution": False, "auto_profile_substitution": False,
} }
# #685: report typed reconnect blocker without mutating config or exiting. # #685: report typed reconnect blocker without mutating config or exiting.
-49
View File
@@ -16,7 +16,6 @@ Probe sources
from __future__ import annotations from __future__ import annotations
import os
from typing import Any from typing import Any
@@ -58,56 +57,8 @@ SAFE_ENV_KEYS = (
"GITEA_SERVICE", "GITEA_SERVICE",
"GITEA_EXECUTION_ROLE", "GITEA_EXECUTION_ROLE",
"GITEA_MCP_CONFIG", "GITEA_MCP_CONFIG",
"GITEA_MCP_NAMESPACE",
) )
# Optional launcher-provided env declaring the client-managed MCP namespace
# this process is registered under (e.g. ``gitea-reviewer``). The server
# cannot derive its own IDE namespace name, so the launcher declares it; when
# declared, reviewers/mergers can fail closed on a namespace/task mismatch
# (#690 AC4). Absence means "unknown" — reported, never guessed.
NAMESPACE_ENV = "GITEA_MCP_NAMESPACE"
def configured_client_namespace(env: dict[str, str] | None = None) -> str | None:
"""Return the launcher-declared client namespace, or None when unknown."""
source = os.environ if env is None else env
value = (source.get(NAMESPACE_ENV) or "").strip()
return value or None
def namespace_provenance(
task: str | None = None,
*,
active_profile: str | None = None,
env: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Report configured client namespace vs active execution profile (#690).
When *task* carries a required namespace (``TASK_REQUIRED_NAMESPACES``)
and the launcher declared a different one, ``mismatch`` is True and the
caller must fail closed for that task. An undeclared namespace is
reported as unknown — never treated as proof either way.
"""
configured = configured_client_namespace(env)
required = TASK_REQUIRED_NAMESPACES.get(task or "")
mismatch = bool(configured and required and configured != required)
reasons: list[str] = []
if mismatch:
reasons.append(
f"configured client namespace '{configured}' does not match "
f"required namespace '{required}' for task '{task}' (fail closed)"
)
return {
"configured_namespace": configured,
"namespace_source": NAMESPACE_ENV if configured else "unknown",
"active_profile": active_profile,
"requested_task": task,
"required_namespace": required,
"mismatch": mismatch,
"reasons": reasons,
}
def _as_list(value: Any) -> list[str] | None: def _as_list(value: Any) -> list[str] | None:
if value is None: if value is None:
-2
View File
@@ -41,7 +41,6 @@ def _reset_mutation_authority(monkeypatch):
"GITEA_REVIEWER_WORKTREE", "GITEA_REVIEWER_WORKTREE",
"GITEA_MERGER_WORKTREE", "GITEA_MERGER_WORKTREE",
"GITEA_RECONCILER_WORKTREE", "GITEA_RECONCILER_WORKTREE",
"GITEA_MCP_NAMESPACE",
]: ]:
monkeypatch.delenv(env_key, raising=False) monkeypatch.delenv(env_key, raising=False)
@@ -116,7 +115,6 @@ def _reset_mutation_authority(monkeypatch):
monkeypatch.setattr(mcp_server, "_ACTOR_IDENTITY_CACHE", {}) monkeypatch.setattr(mcp_server, "_ACTOR_IDENTITY_CACHE", {})
monkeypatch.setattr(mcp_server, "_REVIEW_DECISION_LOCK", None) monkeypatch.setattr(mcp_server, "_REVIEW_DECISION_LOCK", None)
monkeypatch.setattr(mcp_server, "_LIVE_NAMESPACE_HEALTH", {}) monkeypatch.setattr(mcp_server, "_LIVE_NAMESPACE_HEALTH", {})
monkeypatch.setattr(mcp_server, "_PROFILE_SWITCH_INVALIDATION", None)
monkeypatch.setattr(mcp_server, "_preflight_whoami_called", False) monkeypatch.setattr(mcp_server, "_preflight_whoami_called", False)
monkeypatch.setattr(mcp_server, "_preflight_capability_called", False) monkeypatch.setattr(mcp_server, "_preflight_capability_called", False)
monkeypatch.setattr(mcp_server, "_preflight_resolved_role", None) monkeypatch.setattr(mcp_server, "_preflight_resolved_role", None)
@@ -1,274 +0,0 @@
"""Regression coverage for #690: cross-role profile activation invalidation.
A mid-run profile switch (e.g. reviewer → author → reviewer) must invalidate
workflow-load proof, reviewer lease binding, review decision lock, live
namespace health, and preflight identity/capability stamps, and must require
a full reviewer preflight before any formal verdict. Namespace provenance
must be reported and fail closed on task/namespace mismatch.
"""
import json
import os
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import gitea_config
import mcp_namespace_health
import mcp_server
import mcp_session_state
import review_workflow_load
import reviewer_pr_lease
from tests.test_runtime_clarity import CONFIG_SWITCHING_ENABLED
class TestProfileSwitchReviewGuard(unittest.TestCase):
def setUp(self):
self._remotes_patch = patch.dict(mcp_server.REMOTES, {
"dadeschools": {"host": "gitea.example.com", "org": "Example-Org", "repo": "Example-Repo"},
"prgs": {"host": "gitea.example.com", "org": "Example-Org", "repo": "Example-Repo"},
})
self._remotes_patch.start()
mcp_server._IDENTITY_CACHE.clear()
gitea_config._active_profile_override = None
self._dir = tempfile.TemporaryDirectory()
self.config_path = os.path.join(self._dir.name, "profiles.json")
with open(self.config_path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(CONFIG_SWITCHING_ENABLED))
def tearDown(self):
self._remotes_patch.stop()
mcp_server._IDENTITY_CACHE.clear()
gitea_config._active_profile_override = None
self._dir.cleanup()
def _env(self, profile="reviewer-profile"):
return {
"GITEA_MCP_CONFIG": self.config_path,
"GITEA_MCP_PROFILE": profile,
"GITEA_TOKEN_AUTHOR": "author-pass",
"GITEA_TOKEN_REVIEWER": "reviewer-pass",
"GITEA_TOKEN_MERGER": "merger-pass",
}
def _seed_contaminated_review_state(self):
"""Simulate an in-flight reviewer run under reviewer-profile."""
mcp_server._preflight_whoami_called = True
mcp_server._preflight_capability_called = True
mcp_server._preflight_resolved_role = "reviewer"
mcp_server._preflight_resolved_task = "review_pr"
review_workflow_load._REVIEW_WORKFLOW_LOAD = {"loaded": True}
mcp_server._REVIEW_DECISION_LOCK = {
"session_profile": "reviewer-profile",
"final_review_decision_ready": True,
"ready_pr_number": 688,
}
reviewer_pr_lease.record_session_lease(
{"session_id": "lease-session-1", "pr_number": 688}
)
mcp_server._LIVE_NAMESPACE_HEALTH["gitea-reviewer"] = {
"namespace": "gitea-reviewer",
"healthy": True,
"ide_namespace_proven": True,
}
# Durable records keyed by the reviewer identity must also be cleared.
mcp_session_state.save_state(
kind=mcp_session_state.KIND_WORKFLOW_LOAD,
payload={"loaded": True},
profile_identity="reviewer-profile",
)
mcp_session_state.save_state(
kind=mcp_session_state.KIND_DECISION_LOCK,
payload={"final_review_decision_ready": True, "ready_pr_number": 688},
profile_identity="reviewer-profile",
)
def _activate(self, target, logins):
with patch.object(
mcp_server, "get_auth_header", side_effect=[f"token p" for _ in logins]
), patch.object(
mcp_server, "api_request", side_effect=[{"login": l} for l in logins]
), patch.object(
mcp_server,
"_workspace_repository_slug",
return_value="Example-Org/Example-Repo",
), patch.object(
mcp_server, "_canonical_repository_slug", return_value=(None, [])
):
return mcp_server.gitea_activate_profile(profile_name=target)
# -----------------------------------------------------------------
# AC1/AC2/AC3: switch invalidates review state; re-preflight required
# -----------------------------------------------------------------
def test_switch_invalidates_review_state_and_blocks_verdict(self):
with patch.dict(os.environ, self._env("reviewer-profile"), clear=True):
self._seed_contaminated_review_state()
res = self._activate("author-profile", ["reviewer-user", "author-user"])
self.assertTrue(res["success"])
self.assertTrue(res["re_preflight_required"])
inv = res["review_state_invalidation"]
self.assertEqual(inv["from_profile"], "reviewer-profile")
self.assertEqual(inv["to_profile"], "author-profile")
for item in (
"preflight_identity_capability",
"review_workflow_load",
"review_decision_lock",
"reviewer_session_lease",
"live_namespace_health",
):
self.assertIn(item, inv["invalidated"])
# In-memory state cleared.
self.assertFalse(mcp_server._preflight_whoami_called)
self.assertFalse(mcp_server._preflight_capability_called)
self.assertIsNone(mcp_server._preflight_resolved_task)
self.assertIsNone(review_workflow_load._REVIEW_WORKFLOW_LOAD)
self.assertIsNone(mcp_server._REVIEW_DECISION_LOCK)
self.assertIsNone(reviewer_pr_lease.get_session_lease())
self.assertEqual(mcp_server._LIVE_NAMESPACE_HEALTH, {})
self.assertIsNotNone(mcp_server._PROFILE_SWITCH_INVALIDATION)
# Durable records keyed by the reviewer identity are gone.
self.assertIsNone(
mcp_session_state.load_state(
kind=mcp_session_state.KIND_WORKFLOW_LOAD,
profile_identity="reviewer-profile",
)
)
self.assertIsNone(
mcp_session_state.load_state(
kind=mcp_session_state.KIND_DECISION_LOCK,
profile_identity="reviewer-profile",
)
)
# A formal verdict without re-preflight fails closed.
reasons = mcp_server.check_review_decision_gate(
688, "APPROVE", final_review_decision_ready=True
)
self.assertTrue(reasons)
def test_switch_back_cannot_resurrect_stale_review_run(self):
with patch.dict(os.environ, self._env("reviewer-profile"), clear=True):
self._seed_contaminated_review_state()
self._activate("author-profile", ["reviewer-user", "author-user"])
res = self._activate("reviewer-profile", ["author-user", "reviewer-user"])
self.assertTrue(res["success"])
# The pre-switch review run must not reappear.
self.assertIsNone(mcp_server._REVIEW_DECISION_LOCK)
self.assertIsNone(review_workflow_load._REVIEW_WORKFLOW_LOAD)
self.assertIsNone(reviewer_pr_lease.get_session_lease())
status = review_workflow_load.workflow_load_status()
self.assertFalse(status["workflow_load_valid"])
reasons = mcp_server.check_review_decision_gate(
688, "APPROVE", final_review_decision_ready=True
)
self.assertTrue(reasons)
def test_same_profile_reactivation_keeps_state(self):
with patch.dict(os.environ, self._env("reviewer-profile"), clear=True):
self._seed_contaminated_review_state()
res = self._activate("reviewer-profile", ["reviewer-user", "reviewer-user"])
self.assertTrue(res["success"], res)
self.assertNotIn("review_state_invalidation", res)
self.assertIsNotNone(mcp_server._REVIEW_DECISION_LOCK)
self.assertTrue(mcp_server._preflight_whoami_called)
def test_clean_repreflight_after_switch_allows_gate(self):
with patch.dict(os.environ, self._env("reviewer-profile"), clear=True):
self._seed_contaminated_review_state()
self._activate("author-profile", ["reviewer-user", "author-user"])
self._activate("reviewer-profile", ["author-user", "reviewer-user"])
# Re-establish the full reviewer preflight under the new profile.
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check(
"capability", resolved_role="reviewer", resolved_task="review_pr"
)
mcp_server.init_review_decision_lock("dadeschools", "review_pr")
lock = mcp_server._load_review_decision_lock()
self.assertIsNotNone(lock)
lock.update(
{
"final_review_decision_ready": True,
"ready_pr_number": 688,
"ready_action": "APPROVE",
"ready_remote": "dadeschools",
"ready_org": "Example-Org",
"ready_repo": "Example-Repo",
}
)
mcp_server._save_review_decision_lock(lock)
with patch.object(
mcp_server, "_review_workflow_load_gate_reasons", return_value=[]
):
reasons = mcp_server.check_review_decision_gate(
688,
"APPROVE",
final_review_decision_ready=True,
remote="dadeschools",
)
self.assertEqual(reasons, [])
# -----------------------------------------------------------------
# AC4: namespace provenance reporting + fail-closed mismatch
# -----------------------------------------------------------------
def test_namespace_provenance_mismatch_detection(self):
prov = mcp_namespace_health.namespace_provenance(
task="review_pr",
active_profile="reviewer-profile",
env={"GITEA_MCP_NAMESPACE": "gitea-author"},
)
self.assertTrue(prov["mismatch"])
self.assertEqual(prov["required_namespace"], "gitea-reviewer")
prov_ok = mcp_namespace_health.namespace_provenance(
task="review_pr",
active_profile="reviewer-profile",
env={"GITEA_MCP_NAMESPACE": "gitea-reviewer"},
)
self.assertFalse(prov_ok["mismatch"])
prov_unknown = mcp_namespace_health.namespace_provenance(
task="review_pr", active_profile="reviewer-profile", env={}
)
self.assertIsNone(prov_unknown["configured_namespace"])
self.assertFalse(prov_unknown["mismatch"])
self.assertEqual(prov_unknown["namespace_source"], "unknown")
@patch("mcp_server.api_request", return_value={"login": "reviewer-user"})
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
def test_whoami_reports_namespace_provenance(self, _auth, _api):
env = self._env("reviewer-profile")
env["GITEA_MCP_NAMESPACE"] = "gitea-reviewer"
with patch.dict(os.environ, env, clear=True):
res = mcp_server.gitea_whoami(remote="dadeschools")
prov = res["namespace_provenance"]
self.assertEqual(prov["configured_namespace"], "gitea-reviewer")
self.assertEqual(prov["active_profile"], "reviewer-profile")
self.assertFalse(prov["mismatch"])
@patch("mcp_server.api_request", return_value={"login": "reviewer-user"})
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
def test_resolve_fails_closed_on_namespace_mismatch(self, _auth, _api):
env = self._env("reviewer-profile")
env["GITEA_MCP_NAMESPACE"] = "gitea-author"
with patch.dict(os.environ, env, clear=True):
res = mcp_server.gitea_resolve_task_capability(
task="review_pr", kwargs="{}", remote="dadeschools"
)
self.assertFalse(res["allowed_in_current_session"])
self.assertTrue(res["namespace_provenance"]["mismatch"])
self.assertTrue(
any("namespace" in g for g in res["task_role_guidance"])
)
if __name__ == "__main__":
unittest.main()
+190
View File
@@ -0,0 +1,190 @@
"""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,6 +87,11 @@ from webui.notifications import (
from webui.notification_views import render_notifications_page from webui.notification_views import render_notifications_page
from webui import request_service from webui import request_service
from webui.request_views import render_requests_page 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"}) _READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
_AUDIT_MUTATION_PATHS = frozenset({"/audit", "/api/audit"}) _AUDIT_MUTATION_PATHS = frozenset({"/audit", "/api/audit"})
@@ -910,6 +915,19 @@ async def api_notifications(request: Request) -> JSONResponse:
data = notifications_snapshot_to_dict(snap) data = notifications_snapshot_to_dict(snap)
return JSONResponse(data) 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]: def _default_request_scope() -> dict[str, str]:
"""Resolve remote/org/repo from the project registry for request forms. """Resolve remote/org/repo from the project registry for request forms.
@@ -1075,6 +1093,9 @@ def create_app(*, bind_host: str | None = None) -> Starlette:
Route("/api/analytics", api_v1_analytics, methods=["GET"]), Route("/api/analytics", api_v1_analytics, methods=["GET"]),
Route("/api/v1/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("/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("/audit", audit, methods=["GET", "POST"]),
Route("/api/audit", api_audit, methods=["GET", "POST"]), Route("/api/audit", api_audit, methods=["GET", "POST"]),
Route("/worktrees", worktrees, methods=["GET"]), Route("/worktrees", worktrees, methods=["GET"]),
+23
View File
@@ -317,6 +317,29 @@ _ACTION_SPECS: tuple[ConsoleAction, ...] = (
phase=2, phase=2,
summary="Run reconciler cleanup for merged or superseded PR branches.", 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 # #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 # the allocator reserve it. This is the one Phase 2 action whose execution
# path is actually implemented (``webui.request_service``), so it carries # path is actually implemented (``webui.request_service``), so it carries
+5
View File
@@ -185,6 +185,11 @@ def build_action_registry() -> ActionRegistry:
"console.rebind_session_worktree", "Rebind session worktree to verified lease."), "console.rebind_session_worktree", "Rebind session worktree to verified lease."),
("system.reconcile_cleanups", "Reconcile cleanups", "reconcile_cleanups", ("system.reconcile_cleanups", "Reconcile cleanups", "reconcile_cleanups",
"console.reconcile_cleanups", "Run reconciler cleanup for merged or superseded PRs."), "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( actions = tuple(
GatedAction( GatedAction(
+1
View File
@@ -73,6 +73,7 @@ NAV_GROUPS: tuple[NavGroup, ...] = (
)), )),
NavGroup("Insights", ( NavGroup("Insights", (
NavItem("/insights", "Insights", "stub"), NavItem("/insights", "Insights", "stub"),
NavItem("/observability", "Observability"),
NavItem("/analytics", "Analytics"), NavItem("/analytics", "Analytics"),
NavItem("/audit", "Audit"), NavItem("/audit", "Audit"),
)), )),
+275
View File
@@ -0,0 +1,275 @@
"""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
@@ -0,0 +1,143 @@
"""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)