4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
197 lines
7.9 KiB
Python
197 lines
7.9 KiB
Python
"""Tests for the Sentry issue-fix tool: gating, context, lifecycle, error_kind."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import httpx
|
|
|
|
from integrations.coding_agent import CodingResult
|
|
from integrations.sentry import SentryConfig
|
|
from tools.cross_vendor.fix_sentry_issue import FixSentryIssueTool, fix_sentry_issue
|
|
from tools.cross_vendor.fix_sentry_issue.context import gather_issue_context
|
|
|
|
_CONFIG = "tools.cross_vendor.fix_sentry_issue.context.sentry_config_from_env"
|
|
_GET_ISSUE = "tools.cross_vendor.fix_sentry_issue.context.get_sentry_issue"
|
|
_VERIFY = "tools.cross_vendor.fix_sentry_issue.runner.verify_coding_agent"
|
|
_RUN = "tools.cross_vendor.fix_sentry_issue.runner.run_coding_task"
|
|
|
|
_ISSUE = {
|
|
"title": "TypeError: 'NoneType' object is not subscriptable",
|
|
"culprit": "app.handlers.process_event",
|
|
"level": "error",
|
|
"count": "42",
|
|
"metadata": {
|
|
"type": "TypeError",
|
|
"value": "'NoneType' object is not subscriptable",
|
|
"filename": "app/handlers.py",
|
|
"function": "process_event",
|
|
},
|
|
}
|
|
_URL = "https://acme.sentry.io/issues/12345/"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# metadata + availability
|
|
# --------------------------------------------------------------------------- #
|
|
def test_metadata_is_mutating_on_investigation_surface() -> None:
|
|
t = fix_sentry_issue
|
|
assert t.name == "fix_sentry_issue"
|
|
assert t.source == "sentry"
|
|
assert t.side_effect_level == "mutating"
|
|
assert t.requires_approval is True
|
|
assert t.surfaces == ("investigation",)
|
|
assert t.input_schema["required"] == ["sentry_url"]
|
|
assert "error_kind" in t.outputs
|
|
assert t.metadata().name == "fix_sentry_issue"
|
|
|
|
|
|
def test_is_available_off_by_default_then_opt_in() -> None:
|
|
with patch.dict(os.environ, {}, clear=False):
|
|
os.environ.pop("PI_ISSUE_FIX_ENABLED", None)
|
|
assert fix_sentry_issue.is_available({}) is False
|
|
with patch.dict(os.environ, {"PI_ISSUE_FIX_ENABLED": "1"}, clear=False):
|
|
assert fix_sentry_issue.is_available({}) is True
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# context building
|
|
# --------------------------------------------------------------------------- #
|
|
@patch(_GET_ISSUE, return_value=_ISSUE)
|
|
@patch(_CONFIG, return_value=MagicMock())
|
|
def test_gather_issue_context_builds_masked_task(
|
|
_mock_cfg: MagicMock, _mock_issue: MagicMock
|
|
) -> None:
|
|
ctx = gather_issue_context(_URL)
|
|
assert ctx.issue_id == "12345"
|
|
assert "Issue:" in ctx.task
|
|
assert "TypeError" in ctx.task
|
|
assert "app/handlers.py" in ctx.task
|
|
|
|
|
|
@patch(_GET_ISSUE, return_value=_ISSUE)
|
|
@patch(_CONFIG, return_value=SentryConfig(organization_slug="env-org", auth_token="tok"))
|
|
def test_gather_uses_org_from_url_over_config(_mock_cfg: MagicMock, mock_issue: MagicMock) -> None:
|
|
# URL org is "acme"; config org is "env-org" — the URL must win.
|
|
gather_issue_context("https://acme.sentry.io/issues/12345/")
|
|
used_config = mock_issue.call_args.kwargs["config"]
|
|
assert used_config.organization_slug == "acme"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# run() lifecycle + error_kind
|
|
# --------------------------------------------------------------------------- #
|
|
def test_run_disabled() -> None:
|
|
with patch.dict(os.environ, {}, clear=False):
|
|
os.environ.pop("PI_ISSUE_FIX_ENABLED", None)
|
|
out = fix_sentry_issue.run(sentry_url=_URL)
|
|
assert out["success"] is False
|
|
assert out["error_kind"] == "disabled"
|
|
|
|
|
|
def test_run_invalid_url() -> None:
|
|
with patch.dict(os.environ, {"PI_ISSUE_FIX_ENABLED": "1"}, clear=False):
|
|
out = fix_sentry_issue.run(sentry_url="https://github.com/foo/bar/issues/1")
|
|
assert out["error_kind"] == "invalid_input"
|
|
|
|
|
|
@patch(_CONFIG, return_value=None)
|
|
def test_run_sentry_unavailable(_mock_cfg: MagicMock) -> None:
|
|
with patch.dict(os.environ, {"PI_ISSUE_FIX_ENABLED": "1"}, clear=False):
|
|
out = fix_sentry_issue.run(sentry_url=_URL)
|
|
assert out["error_kind"] == "sentry_unavailable"
|
|
|
|
|
|
@patch(_GET_ISSUE, return_value={})
|
|
@patch(_CONFIG, return_value=MagicMock())
|
|
def test_run_issue_not_found_empty_response(_mock_cfg: MagicMock, _mock_issue: MagicMock) -> None:
|
|
with patch.dict(os.environ, {"PI_ISSUE_FIX_ENABLED": "1"}, clear=False):
|
|
out = fix_sentry_issue.run(sentry_url=_URL)
|
|
assert out["error_kind"] == "issue_not_found"
|
|
|
|
|
|
def _http_status_error(status: int) -> httpx.HTTPStatusError:
|
|
req = httpx.Request("GET", "https://acme.sentry.io/api/0/issues/12345/")
|
|
return httpx.HTTPStatusError("err", request=req, response=httpx.Response(status, request=req))
|
|
|
|
|
|
@patch(_GET_ISSUE, side_effect=_http_status_error(404))
|
|
@patch(_CONFIG, return_value=MagicMock())
|
|
def test_run_issue_not_found_on_http_404(_mock_cfg: MagicMock, _mock_issue: MagicMock) -> None:
|
|
# The common real failure: valid URL, issue id doesn't exist -> Sentry 404.
|
|
with patch.dict(os.environ, {"PI_ISSUE_FIX_ENABLED": "1"}, clear=False):
|
|
out = fix_sentry_issue.run(sentry_url=_URL)
|
|
assert out["error_kind"] == "issue_not_found"
|
|
|
|
|
|
@patch(_GET_ISSUE, side_effect=_http_status_error(403))
|
|
@patch(_CONFIG, return_value=MagicMock())
|
|
def test_run_sentry_auth_error_on_http_403(_mock_cfg: MagicMock, _mock_issue: MagicMock) -> None:
|
|
with patch.dict(os.environ, {"PI_ISSUE_FIX_ENABLED": "1"}, clear=False):
|
|
out = fix_sentry_issue.run(sentry_url=_URL)
|
|
assert out["error_kind"] == "sentry_unavailable"
|
|
|
|
|
|
@patch(_GET_ISSUE, side_effect=httpx.ConnectError("boom"))
|
|
@patch(_CONFIG, return_value=MagicMock())
|
|
def test_run_sentry_network_error(_mock_cfg: MagicMock, _mock_issue: MagicMock) -> None:
|
|
with patch.dict(os.environ, {"PI_ISSUE_FIX_ENABLED": "1"}, clear=False):
|
|
out = fix_sentry_issue.run(sentry_url=_URL)
|
|
assert out["error_kind"] == "sentry_unavailable"
|
|
|
|
|
|
@patch(_VERIFY, return_value=(False, "pi not installed"))
|
|
@patch(_GET_ISSUE, return_value=_ISSUE)
|
|
@patch(_CONFIG, return_value=MagicMock())
|
|
def test_run_cli_unavailable(
|
|
_mock_cfg: MagicMock, _mock_issue: MagicMock, _mock_verify: MagicMock
|
|
) -> None:
|
|
with patch.dict(os.environ, {"PI_ISSUE_FIX_ENABLED": "1"}, clear=False):
|
|
out = fix_sentry_issue.run(sentry_url=_URL)
|
|
assert out["error_kind"] == "cli_unavailable"
|
|
|
|
|
|
@patch(_RUN)
|
|
@patch(_VERIFY, return_value=(True, "ok"))
|
|
@patch(_GET_ISSUE, return_value=_ISSUE)
|
|
@patch(_CONFIG, return_value=MagicMock())
|
|
def test_run_success(
|
|
_mock_cfg: MagicMock, _mock_issue: MagicMock, _mock_verify: MagicMock, mock_run: MagicMock
|
|
) -> None:
|
|
mock_run.return_value = CodingResult(
|
|
success=True,
|
|
summary="Guarded the None case in process_event.",
|
|
changed_files=["app/handlers.py"],
|
|
diff="diff --git a/app/handlers.py b/app/handlers.py\n",
|
|
returncode=0,
|
|
)
|
|
with patch.dict(os.environ, {"PI_ISSUE_FIX_ENABLED": "1"}, clear=False):
|
|
out = fix_sentry_issue.run(sentry_url=_URL)
|
|
assert out["success"] is True
|
|
assert out["error_kind"] is None
|
|
assert out["issue_id"] == "12345"
|
|
assert out["changed_files"] == ["app/handlers.py"]
|
|
assert "diff --git" in out["diff"]
|
|
# the synthesized Sentry task was passed to the Pi client
|
|
assert "Sentry issue" in mock_run.call_args.args[0]
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# registry discovery
|
|
# --------------------------------------------------------------------------- #
|
|
def test_registry_discovers_fix_sentry_issue_on_investigation_surface() -> None:
|
|
from tools.registry import get_registered_tool_map
|
|
|
|
investigation = get_registered_tool_map("investigation")
|
|
chat = get_registered_tool_map("chat")
|
|
assert "fix_sentry_issue" in investigation
|
|
assert "fix_sentry_issue" not in chat
|
|
rt = investigation["fix_sentry_issue"]
|
|
assert rt.requires_approval is True
|
|
assert rt.side_effect_level == "mutating"
|
|
|
|
|
|
def test_tool_subclass_constructs() -> None:
|
|
assert isinstance(fix_sentry_issue, FixSentryIssueTool)
|