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
323 lines
11 KiB
Python
323 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import threading
|
|
from datetime import UTC, datetime, timedelta
|
|
from unittest.mock import MagicMock
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from integrations.config_models import IncidentIoIntegrationConfig
|
|
from integrations.incident_io.client import (
|
|
IncidentIoClient,
|
|
_get_incident_write_lock,
|
|
make_incident_io_client,
|
|
)
|
|
|
|
|
|
def _response(payload: dict) -> MagicMock:
|
|
response = MagicMock()
|
|
response.json.return_value = payload
|
|
response.raise_for_status.return_value = None
|
|
response.status_code = 200
|
|
return response
|
|
|
|
|
|
@pytest.fixture
|
|
def client() -> IncidentIoClient:
|
|
return IncidentIoClient(IncidentIoIntegrationConfig(api_key="test-key"))
|
|
|
|
|
|
def test_config_defaults_to_documented_public_api_url() -> None:
|
|
config = IncidentIoIntegrationConfig(api_key="test-key")
|
|
|
|
assert config.base_url == "https://api.incident.io"
|
|
|
|
|
|
def test_config_rejects_non_https_remote_base_url() -> None:
|
|
with pytest.raises(ValueError, match="must use https"):
|
|
IncidentIoIntegrationConfig(
|
|
api_key="test-key",
|
|
base_url="http://169.254.169.254/latest",
|
|
)
|
|
|
|
|
|
def test_make_client_allows_loopback_base_url() -> None:
|
|
created = make_incident_io_client(
|
|
"test-key",
|
|
base_url="http://localhost:8080",
|
|
)
|
|
assert created is not None
|
|
assert created.config.base_url == "http://localhost:8080"
|
|
|
|
|
|
def test_list_incidents_uses_status_category_filter(client: IncidentIoClient, monkeypatch) -> None:
|
|
calls: list[tuple[str, str, dict]] = []
|
|
|
|
def fake_request(method: str, path: str, **kwargs):
|
|
calls.append((method, path, kwargs))
|
|
return _response(
|
|
{
|
|
"incidents": [
|
|
{
|
|
"id": "inc-123",
|
|
"reference": "INC-123",
|
|
"name": "Database outage",
|
|
"incident_status": {"name": "Mitigating", "category": "live"},
|
|
"severity": {"name": "SEV1", "rank": 1},
|
|
}
|
|
],
|
|
"pagination_meta": {"after": "inc-123", "page_size": 1},
|
|
}
|
|
)
|
|
|
|
monkeypatch.setattr(client, "_request", fake_request)
|
|
|
|
result = client.list_incidents(status_category="live", page_size=1)
|
|
|
|
assert result["success"] is True
|
|
assert result["incidents"][0]["status"] == "Mitigating"
|
|
assert result["incidents"][0]["status_category"] == "live"
|
|
assert calls == [
|
|
(
|
|
"GET",
|
|
"/v2/incidents",
|
|
{"params": {"page_size": 1, "status_category[one_of]": "live"}},
|
|
)
|
|
]
|
|
|
|
|
|
def test_context_reads_incident_and_updates(client: IncidentIoClient, monkeypatch) -> None:
|
|
calls: list[tuple[str, str, dict]] = []
|
|
|
|
def fake_request(method: str, path: str, **kwargs):
|
|
calls.append((method, path, kwargs))
|
|
if path == "/v2/incidents/inc-123":
|
|
return _response(
|
|
{
|
|
"incident": {
|
|
"id": "inc-123",
|
|
"reference": "INC-123",
|
|
"name": "Checkout degraded",
|
|
"summary": "Investigating elevated latency",
|
|
"incident_status": {"name": "Investigating", "category": "live"},
|
|
}
|
|
}
|
|
)
|
|
return _response(
|
|
{
|
|
"incident_updates": [
|
|
{
|
|
"id": "upd-1",
|
|
"incident_id": "inc-123",
|
|
"message": "We are investigating",
|
|
"new_incident_status": {"name": "Investigating", "category": "live"},
|
|
}
|
|
]
|
|
}
|
|
)
|
|
|
|
monkeypatch.setattr(client, "_request", fake_request)
|
|
|
|
result = client.get_incident_context("inc-123", update_limit=10)
|
|
|
|
assert result["success"] is True
|
|
assert result["incident"]["summary"] == "Investigating elevated latency"
|
|
assert result["incident_updates"][0]["message"] == "We are investigating"
|
|
assert calls[0][1] == "/v2/incidents/inc-123"
|
|
assert calls[1] == (
|
|
"GET",
|
|
"/v2/incident_updates",
|
|
{"params": {"incident_id": "inc-123", "page_size": 10}},
|
|
)
|
|
|
|
|
|
def test_append_summary_update_uses_supported_edit_endpoint(
|
|
client: IncidentIoClient,
|
|
monkeypatch,
|
|
) -> None:
|
|
calls: list[tuple[str, str, dict]] = []
|
|
summary_store = {"text": "Existing summary"}
|
|
|
|
def fake_request(method: str, path: str, **kwargs):
|
|
calls.append((method, path, kwargs))
|
|
if method == "GET":
|
|
return _response({"incident": {"id": "inc-123", "summary": summary_store["text"]}})
|
|
payload = kwargs["json"]
|
|
summary_store["text"] = payload["incident"]["summary"]
|
|
return _response({"incident": {"id": "inc-123"}})
|
|
|
|
monkeypatch.setattr(client, "_request", fake_request)
|
|
|
|
result = client.append_summary_update(
|
|
"inc-123",
|
|
title="Root cause found",
|
|
body="Bad deploy increased DB latency.",
|
|
)
|
|
|
|
assert result["success"] is True
|
|
assert calls[1][0] == "POST"
|
|
assert calls[1][1] == "/v2/incidents/inc-123/actions/edit"
|
|
payload = calls[1][2]["json"]
|
|
assert payload["notify_incident_channel"] is False
|
|
assert "Existing summary" in payload["incident"]["summary"]
|
|
assert "Root cause found" in payload["incident"]["summary"]
|
|
assert calls[2][0] == "GET"
|
|
assert calls[2][1] == "/v2/incidents/inc-123"
|
|
|
|
|
|
def test_append_summary_update_retries_until_verify_sees_finding(
|
|
monkeypatch,
|
|
) -> None:
|
|
"""Patch class `_request`: instance patches can fail after sleeps in retry loops."""
|
|
client = IncidentIoClient(IncidentIoIntegrationConfig(api_key="test-key"))
|
|
calls: list[tuple[str, str, dict]] = []
|
|
merged_holder = {"text": ""}
|
|
|
|
def fake_request(self: IncidentIoClient, method: str, path: str, **kwargs):
|
|
calls.append((method, path, kwargs))
|
|
if method == "GET":
|
|
get_idx = sum(1 for c in calls if c[0] == "GET")
|
|
if get_idx in {1, 2}:
|
|
summary = "Existing summary"
|
|
else:
|
|
summary = merged_holder["text"]
|
|
return _response({"incident": {"id": "inc-123", "summary": summary}})
|
|
payload = kwargs["json"]
|
|
merged_holder["text"] = payload["incident"]["summary"]
|
|
return _response({"incident": {"id": "inc-123"}})
|
|
|
|
sleeps: list[float] = []
|
|
monkeypatch.setattr(IncidentIoClient, "_request", fake_request)
|
|
monkeypatch.setattr("integrations.incident_io.client.time.sleep", sleeps.append)
|
|
monkeypatch.setattr("integrations.incident_io.client.random.random", lambda: 0.0)
|
|
|
|
result = client.append_summary_update(
|
|
"inc-123",
|
|
title="Lag spike",
|
|
body="Retry path.",
|
|
)
|
|
|
|
assert result["success"] is True
|
|
assert sum(1 for c in calls if c[0] == "POST") == 1
|
|
assert len(sleeps) >= 1
|
|
|
|
|
|
def test_append_summary_update_skips_post_when_finding_already_present(
|
|
client: IncidentIoClient,
|
|
monkeypatch,
|
|
) -> None:
|
|
class FrozenDateTime(datetime):
|
|
@classmethod
|
|
def now(cls, _tz=None):
|
|
return datetime(2099, 1, 1, 0, 0, 0, tzinfo=UTC)
|
|
|
|
monkeypatch.setattr("integrations.incident_io.client.datetime", FrozenDateTime)
|
|
|
|
embedded = "Existing\n\n---\n**OpenSRE finding: Already posted** (2099-01-01 00:00:00 UTC)"
|
|
calls: list[tuple[str, str, dict]] = []
|
|
|
|
def fake_request(method: str, path: str, **kwargs):
|
|
calls.append((method, path, kwargs))
|
|
return _response({"incident": {"id": "inc-123", "summary": embedded}})
|
|
|
|
monkeypatch.setattr(client, "_request", fake_request)
|
|
|
|
result = client.append_summary_update(
|
|
"inc-123",
|
|
title="Already posted",
|
|
body="",
|
|
)
|
|
|
|
assert result["success"] is True
|
|
assert all(c[0] != "POST" for c in calls)
|
|
|
|
|
|
def test_concurrent_append_summary_uses_shared_module_level_lock() -> None:
|
|
"""Two separate client instances targeting the same incident must share one lock."""
|
|
client_a = IncidentIoClient(IncidentIoIntegrationConfig(api_key="key-a"))
|
|
client_b = IncidentIoClient(IncidentIoIntegrationConfig(api_key="key-b"))
|
|
|
|
lock_a = _get_incident_write_lock("inc-shared")
|
|
lock_b = _get_incident_write_lock("inc-shared")
|
|
assert lock_a is lock_b, "Different client instances must share the same lock per incident ID"
|
|
|
|
lock_other = _get_incident_write_lock("inc-other")
|
|
assert lock_other is not lock_a, "Different incident IDs must use different locks"
|
|
|
|
# Verify the clients themselves no longer carry a _write_lock attribute
|
|
assert not hasattr(client_a, "_write_lock")
|
|
assert not hasattr(client_b, "_write_lock")
|
|
|
|
# Confirm concurrent writes from two clients are serialised by the shared lock:
|
|
# the first writer holds the lock while the second waits.
|
|
order: list[str] = []
|
|
barrier = threading.Barrier(2)
|
|
|
|
lock = _get_incident_write_lock("inc-concurrent")
|
|
results: list[bool] = []
|
|
|
|
def holder() -> None:
|
|
with lock:
|
|
order.append("held")
|
|
barrier.wait()
|
|
|
|
def waiter() -> None:
|
|
barrier.wait()
|
|
acquired = lock.acquire(blocking=False)
|
|
results.append(acquired)
|
|
if acquired:
|
|
lock.release()
|
|
|
|
t1 = threading.Thread(target=holder)
|
|
t2 = threading.Thread(target=waiter)
|
|
t1.start()
|
|
t2.start()
|
|
t1.join()
|
|
t2.join()
|
|
|
|
assert results == [False], "Concurrent writer must be blocked while the lock is held"
|
|
|
|
|
|
def test_request_honors_documented_json_rate_limit_retry_after(
|
|
client: IncidentIoClient,
|
|
monkeypatch,
|
|
) -> None:
|
|
retry_at = (datetime.now(UTC) + timedelta(seconds=5)).strftime("%a, %d %b %Y %H:%M:%S UTC")
|
|
rate_limited = _response(
|
|
{
|
|
"type": "too_many_requests",
|
|
"status": 429,
|
|
"rate_limit": {
|
|
"name": "api_key_name",
|
|
"limit": 1200,
|
|
"remaining": 0,
|
|
"retry_after": retry_at,
|
|
},
|
|
}
|
|
)
|
|
rate_limited.status_code = 429
|
|
rate_limited.headers = {}
|
|
rate_limited.raise_for_status.side_effect = httpx.HTTPStatusError(
|
|
"rate limited",
|
|
request=MagicMock(),
|
|
response=rate_limited,
|
|
)
|
|
ok = _response({"incidents": []})
|
|
|
|
request = MagicMock(side_effect=[rate_limited, ok])
|
|
fake_http_client = MagicMock()
|
|
fake_http_client.request = request
|
|
monkeypatch.setattr(client, "_get_client", lambda: fake_http_client)
|
|
|
|
sleeps: list[float] = []
|
|
monkeypatch.setattr("integrations.incident_io.client.time.sleep", sleeps.append)
|
|
monkeypatch.setattr("integrations.incident_io.client.random.random", lambda: 0.0)
|
|
|
|
response = client._request("GET", "/v2/incidents")
|
|
|
|
assert response is ok
|
|
assert request.call_count == 2
|
|
assert sleeps
|
|
assert 0 <= sleeps[0] <= 5
|