Files
tracer-cloud--opensre/tests/tools/test_git_deploy_timeline_tool.py
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

605 lines
23 KiB
Python

"""Tests for GitDeployTimelineTool (function-based, @tool decorated)."""
from __future__ import annotations
from datetime import datetime, timedelta
from unittest.mock import MagicMock, patch
from tests.tools.conftest import BaseToolContract, mock_agent_state
from tools.git_deploy_timeline_tool import (
DEFAULT_WINDOW_MINUTES,
MAX_PER_PAGE,
MAX_WINDOW_MINUTES,
_resolve_window,
_summarize_commit,
get_git_deploy_timeline,
)
class TestGitDeployTimelineToolContract(BaseToolContract):
def get_tool_under_test(self):
return get_git_deploy_timeline.__opensre_registered_tool__
def test_is_available_requires_connection_owner_repo() -> None:
rt = get_git_deploy_timeline.__opensre_registered_tool__
assert (
rt.is_available({"github": {"connection_verified": True, "owner": "org", "repo": "repo"}})
is True
)
assert rt.is_available({"github": {"connection_verified": True}}) is False
assert rt.is_available({}) is False
def test_extract_params_maps_fields() -> None:
rt = get_git_deploy_timeline.__opensre_registered_tool__
sources = mock_agent_state()
params = rt.extract_params(sources)
assert params["owner"] == "my-org"
assert params["repo"] == "my-repo"
assert params["branch"] == "main"
def test_run_returns_unavailable_when_no_config() -> None:
with patch("integrations.github.helpers.github_mcp_config_from_env", return_value=None):
result = get_git_deploy_timeline(owner="org", repo="repo")
assert result["available"] is False
assert result["commits"] == []
def test_run_happy_path_summarizes_commits() -> None:
fake_result = {
"is_error": False,
"tool": "list_commits",
"arguments": {},
"text": "2 commits",
"structured_content": [
{
"sha": "abcdef0123456789",
"html_url": "https://github.com/org/repo/commit/abcdef0",
"commit": {
"author": {"name": "Alice", "date": "2026-04-20T09:15:00Z"},
"committer": {"date": "2026-04-20T09:15:30Z"},
"message": "fix: null deref on empty payload\n\nAdditional body text",
},
},
{
"sha": "fedcba9876543210",
"html_url": "https://github.com/org/repo/commit/fedcba9",
"commit": {
"author": {"name": "Bob", "date": "2026-04-20T10:00:00Z"},
"committer": {"date": "2026-04-20T10:00:10Z"},
"message": "feat: add retry on 502",
},
},
],
"content": [],
}
mock_config = MagicMock()
with (
patch("integrations.github.helpers.github_mcp_config_from_env", return_value=None),
patch(
"integrations.github.helpers.build_github_mcp_config",
return_value=mock_config,
),
patch("tools.git_deploy_timeline_tool.call_github_mcp_tool", return_value=fake_result),
):
result = get_git_deploy_timeline(
owner="org",
repo="repo",
github_url="http://mcp",
github_mode="streamable-http",
github_token="tok",
)
assert result["available"] is True
assert result["commits_count"] == 2
assert len(result["commits"]) == 2
first = result["commits"][0]
assert first["sha"] == "abcdef0123456789"
assert first["short_sha"] == "abcdef0"
assert first["author_name"] == "Alice"
# Only the subject line of the message is kept; body is dropped.
assert first["message_subject"] == "fix: null deref on empty payload"
assert first["url"] == "https://github.com/org/repo/commit/abcdef0"
window = result["window"]
assert "since" in window and "until" in window
assert window["branch"] == "main"
def test_run_passes_time_window_and_branch_to_mcp() -> None:
mock_config = MagicMock()
captured: dict[str, object] = {}
def _fake_call(config, name, arguments):
captured["name"] = name
captured["arguments"] = arguments
return {"is_error": False, "text": "", "structured_content": [], "content": []}
with (
patch("integrations.github.helpers.github_mcp_config_from_env", return_value=None),
patch(
"integrations.github.helpers.build_github_mcp_config",
return_value=mock_config,
),
patch("tools.git_deploy_timeline_tool.call_github_mcp_tool", side_effect=_fake_call),
):
get_git_deploy_timeline(
owner="org",
repo="repo",
branch="release",
since="2026-04-20T08:00:00Z",
until="2026-04-20T10:00:00Z",
github_url="http://mcp",
github_mode="streamable-http",
github_token="tok",
)
assert captured["name"] == "list_commits"
args = captured["arguments"]
assert args["owner"] == "org"
assert args["repo"] == "repo"
assert args["sha"] == "release"
assert args["since"].startswith("2026-04-20T08:00:00")
assert args["until"].startswith("2026-04-20T10:00:00")
def test_run_empty_result_returns_zero_commits() -> None:
fake_result = {
"is_error": False,
"tool": "list_commits",
"arguments": {},
"text": "",
"structured_content": [],
"content": [],
}
mock_config = MagicMock()
with (
patch("integrations.github.helpers.github_mcp_config_from_env", return_value=None),
patch(
"integrations.github.helpers.build_github_mcp_config",
return_value=mock_config,
),
patch("tools.git_deploy_timeline_tool.call_github_mcp_tool", return_value=fake_result),
):
result = get_git_deploy_timeline(
owner="org",
repo="repo",
github_url="http://mcp",
github_mode="streamable-http",
github_token="tok",
)
assert result["commits"] == []
assert result["commits_count"] == 0
# Window metadata is always populated even when there are no commits —
# "0 commits in this window" is itself evidence the RCA step can cite.
assert result["window"]["branch"] == "main"
def test_run_defensive_against_non_list_structured_content() -> None:
# MCP has occasionally been observed to return a dict under
# structured_content (e.g. when the upstream paginates differently). The
# tool must never crash on that shape — it should surface an empty list.
fake_result = {
"is_error": False,
"tool": "list_commits",
"arguments": {},
"text": "",
"structured_content": {"unexpected": "shape"},
"content": [],
}
mock_config = MagicMock()
with (
patch("integrations.github.helpers.github_mcp_config_from_env", return_value=None),
patch(
"integrations.github.helpers.build_github_mcp_config",
return_value=mock_config,
),
patch("tools.git_deploy_timeline_tool.call_github_mcp_tool", return_value=fake_result),
):
result = get_git_deploy_timeline(
owner="org",
repo="repo",
github_url="http://mcp",
github_mode="streamable-http",
github_token="tok",
)
assert result["commits"] == []
assert result["commits_count"] == 0
def test_run_passes_per_page_to_mcp() -> None:
mock_config = MagicMock()
captured: dict[str, object] = {}
def _fake_call(config, name, arguments):
captured["arguments"] = arguments
return {"is_error": False, "text": "", "structured_content": [], "content": []}
with (
patch("integrations.github.helpers.github_mcp_config_from_env", return_value=None),
patch(
"integrations.github.helpers.build_github_mcp_config",
return_value=mock_config,
),
patch("tools.git_deploy_timeline_tool.call_github_mcp_tool", side_effect=_fake_call),
):
get_git_deploy_timeline(
owner="org",
repo="repo",
per_page=50,
github_url="http://mcp",
github_mode="streamable-http",
github_token="tok",
)
# MCP / GitHub REST API spells this camelCase; the tool must translate.
assert captured["arguments"]["perPage"] == 50
def test_run_clamps_per_page_to_api_maximum() -> None:
# GitHub REST list_commits caps per_page at 100. If a caller asks for more
# the request silently truncates upstream, and our commits_count would be
# wrong. We enforce the ceiling explicitly.
mock_config = MagicMock()
captured: dict[str, object] = {}
def _fake_call(config, name, arguments):
captured["arguments"] = arguments
return {"is_error": False, "text": "", "structured_content": [], "content": []}
with (
patch("integrations.github.helpers.github_mcp_config_from_env", return_value=None),
patch(
"integrations.github.helpers.build_github_mcp_config",
return_value=mock_config,
),
patch("tools.git_deploy_timeline_tool.call_github_mcp_tool", side_effect=_fake_call),
):
result = get_git_deploy_timeline(
owner="org",
repo="repo",
per_page=500,
github_url="http://mcp",
github_mode="streamable-http",
github_token="tok",
)
assert captured["arguments"]["perPage"] == MAX_PER_PAGE
assert result["window"]["per_page"] == MAX_PER_PAGE
def test_run_flags_window_truncated_when_page_is_full() -> None:
# When the MCP returns exactly per_page commits, we don't know whether
# more exist in the window. The window.truncated flag warns the agent
# it may be looking at partial data.
full_page = [
{
"sha": f"{i:040x}",
"html_url": "",
"commit": {
"author": {"name": "A", "date": "2026-04-20T09:00:00Z"},
"committer": {"date": "2026-04-20T09:00:01Z"},
"message": f"commit {i}",
},
}
for i in range(5)
]
fake_result = {
"is_error": False,
"text": "5 commits",
"structured_content": full_page,
"content": [],
}
mock_config = MagicMock()
with (
patch("integrations.github.helpers.github_mcp_config_from_env", return_value=None),
patch(
"integrations.github.helpers.build_github_mcp_config",
return_value=mock_config,
),
patch("tools.git_deploy_timeline_tool.call_github_mcp_tool", return_value=fake_result),
):
result = get_git_deploy_timeline(
owner="org",
repo="repo",
per_page=5,
github_url="http://mcp",
github_mode="streamable-http",
github_token="tok",
)
assert result["commits_count"] == 5
assert result["window"]["truncated"] is True
def test_run_flags_window_not_truncated_when_fewer_than_page() -> None:
fake_result = {
"is_error": False,
"text": "",
"structured_content": [
{
"sha": "abc",
"html_url": "",
"commit": {
"author": {"name": "A", "date": "2026-04-20T09:00:00Z"},
"committer": {"date": "2026-04-20T09:00:01Z"},
"message": "one commit",
},
}
],
"content": [],
}
mock_config = MagicMock()
with (
patch("integrations.github.helpers.github_mcp_config_from_env", return_value=None),
patch(
"integrations.github.helpers.build_github_mcp_config",
return_value=mock_config,
),
patch("tools.git_deploy_timeline_tool.call_github_mcp_tool", return_value=fake_result),
):
result = get_git_deploy_timeline(
owner="org",
repo="repo",
per_page=30,
github_url="http://mcp",
github_mode="streamable-http",
github_token="tok",
)
assert result["commits_count"] == 1
assert result["window"]["truncated"] is False
# ---------------------------------------------------------------------------
# Shared incident window integration (PR 2)
# ---------------------------------------------------------------------------
def _shared_window_dict(since: str, until: str) -> dict:
"""Build a shared incident_window dict in the shape extract_alert produces."""
return {
"_schema_version": 1,
"since": since,
"until": until,
"source": "alert.startsAt",
"confidence": 1.0,
}
def _run_with_shared_window(
shared_window: dict | None,
*,
since: str = "",
until: str = "",
window_minutes_before_alert: int | None = None,
) -> tuple[dict, dict]:
"""Helper: run the tool with a stubbed MCP and return (kwargs_to_mcp, payload)."""
captured: dict = {}
def _fake_call(config, name, arguments):
captured["arguments"] = arguments
return {"is_error": False, "text": "", "structured_content": [], "content": []}
mock_config = MagicMock()
with (
patch("integrations.github.helpers.github_mcp_config_from_env", return_value=None),
patch(
"integrations.github.helpers.build_github_mcp_config",
return_value=mock_config,
),
patch("tools.git_deploy_timeline_tool.call_github_mcp_tool", side_effect=_fake_call),
):
result = get_git_deploy_timeline(
owner="org",
repo="repo",
since=since,
until=until,
window_minutes_before_alert=window_minutes_before_alert,
shared_incident_window=shared_window,
github_url="http://mcp",
github_mode="streamable-http",
github_token="tok",
)
return captured, result
def test_extract_params_passes_shared_incident_window_through() -> None:
"""The tool's extract_params reads ``_meta.incident_window`` from sources
and threads it as the ``shared_incident_window`` kwarg."""
rt = get_git_deploy_timeline.__opensre_registered_tool__
sources = mock_agent_state()
sources["_meta"] = {
"incident_window": _shared_window_dict("2026-04-20T08:00:00Z", "2026-04-20T10:00:00Z")
}
params = rt.extract_params(sources)
assert params["shared_incident_window"] == sources["_meta"]["incident_window"]
def test_extract_params_handles_missing_meta_key() -> None:
"""If ``_meta`` is absent (e.g. extract_alert hasn't run yet),
extract_params returns ``shared_incident_window=None`` cleanly."""
rt = get_git_deploy_timeline.__opensre_registered_tool__
params = rt.extract_params(mock_agent_state())
assert params["shared_incident_window"] is None
def test_extract_params_defensive_against_non_dict_meta() -> None:
"""If ``_meta`` is ever populated with a non-dict (e.g. a future bug
upstream), extract_params must not raise — it degrades to None."""
rt = get_git_deploy_timeline.__opensre_registered_tool__
sources = mock_agent_state()
sources["_meta"] = "not-a-dict" # type: ignore[assignment]
params = rt.extract_params(sources)
assert params["shared_incident_window"] is None
def test_extract_params_defensive_against_non_dict_incident_window() -> None:
"""If the nested incident_window is non-dict, same degradation."""
rt = get_git_deploy_timeline.__opensre_registered_tool__
sources = mock_agent_state()
sources["_meta"] = {"incident_window": "garbage"}
params = rt.extract_params(sources)
assert params["shared_incident_window"] is None
def test_run_uses_shared_window_when_no_caller_override() -> None:
"""When neither since/until nor window_minutes_before_alert is given,
the tool MUST use the shared incident window."""
shared = _shared_window_dict("2026-04-20T08:00:00Z", "2026-04-20T10:00:00Z")
captured, result = _run_with_shared_window(shared)
assert captured["arguments"]["since"] == "2026-04-20T08:00:00Z"
assert captured["arguments"]["until"] == "2026-04-20T10:00:00Z"
assert result["window"]["source"] == "shared_incident_window"
def test_run_caller_since_until_overrides_shared_window() -> None:
"""Explicit since/until from the caller wins over the shared window
and is reported as ``caller_explicit`` in the window source."""
shared = _shared_window_dict("2026-04-20T08:00:00Z", "2026-04-20T10:00:00Z")
captured, result = _run_with_shared_window(
shared,
since="2026-04-20T11:00:00Z",
until="2026-04-20T11:30:00Z",
)
assert captured["arguments"]["since"] == "2026-04-20T11:00:00Z"
assert captured["arguments"]["until"] == "2026-04-20T11:30:00Z"
assert result["window"]["source"] == "caller_explicit"
def test_run_caller_window_minutes_overrides_shared_window() -> None:
"""Explicit window_minutes_before_alert from the caller also wins
and is reported as ``caller_explicit``."""
shared = _shared_window_dict("2026-04-20T08:00:00Z", "2026-04-20T10:00:00Z")
_, result = _run_with_shared_window(shared, window_minutes_before_alert=30)
assert result["window"]["source"] == "caller_explicit"
def test_run_falls_back_to_default_when_no_shared_window() -> None:
"""Backward compat: no shared window present, no overrides → existing
DEFAULT_WINDOW_MINUTES default."""
captured, result = _run_with_shared_window(None)
# 'until' should be ~now, 'since' should be 120 min before now.
until_dt = datetime.fromisoformat(captured["arguments"]["until"].replace("Z", "+00:00"))
since_dt = datetime.fromisoformat(captured["arguments"]["since"].replace("Z", "+00:00"))
assert (until_dt - since_dt) == timedelta(minutes=DEFAULT_WINDOW_MINUTES)
assert result["window"]["source"] == "tool_default"
def test_run_malformed_shared_window_falls_back_to_default() -> None:
"""A malformed shared window dict must not crash; tool falls back."""
captured, result = _run_with_shared_window({"junk": "not a window"})
until_dt = datetime.fromisoformat(captured["arguments"]["until"].replace("Z", "+00:00"))
since_dt = datetime.fromisoformat(captured["arguments"]["since"].replace("Z", "+00:00"))
assert (until_dt - since_dt) == timedelta(minutes=DEFAULT_WINDOW_MINUTES)
assert result["window"]["source"] == "tool_default"
# ---------------------------------------------------------------------------
# _resolve_window
# ---------------------------------------------------------------------------
def test_resolve_window_defaults_to_default_minutes() -> None:
since, until = _resolve_window("", "", None)
since_dt = datetime.fromisoformat(since.replace("Z", "+00:00"))
until_dt = datetime.fromisoformat(until.replace("Z", "+00:00"))
span = until_dt - since_dt
assert span == timedelta(minutes=DEFAULT_WINDOW_MINUTES)
def test_resolve_window_honours_explicit_since_and_until() -> None:
since, until = _resolve_window("2026-04-20T08:00:00Z", "2026-04-20T09:30:00Z", None)
assert since == "2026-04-20T08:00:00Z"
assert until == "2026-04-20T09:30:00Z"
def test_resolve_window_honours_window_minutes_when_since_missing() -> None:
since, until = _resolve_window("", "2026-04-20T12:00:00Z", 30)
assert since == "2026-04-20T11:30:00Z"
assert until == "2026-04-20T12:00:00Z"
def test_resolve_window_clamps_to_max_span() -> None:
# Caller asks for 60 days — we only allow MAX_WINDOW_MINUTES.
since, until = _resolve_window("", "2026-04-20T00:00:00Z", 60 * 24 * 60)
since_dt = datetime.fromisoformat(since.replace("Z", "+00:00"))
until_dt = datetime.fromisoformat(until.replace("Z", "+00:00"))
assert (until_dt - since_dt) == timedelta(minutes=MAX_WINDOW_MINUTES)
def test_resolve_window_treats_malformed_since_as_missing() -> None:
# Garbage 'since' falls through to the default-window branch rather than raising.
since, until = _resolve_window("not-a-date", "", None)
since_dt = datetime.fromisoformat(since.replace("Z", "+00:00"))
until_dt = datetime.fromisoformat(until.replace("Z", "+00:00"))
assert until_dt - since_dt == timedelta(minutes=DEFAULT_WINDOW_MINUTES)
def test_resolve_window_rejects_inverted_range() -> None:
# since > until is a caller mistake. Rather than pass an impossible range to
# MCP, the helper discards the bad 'since' and falls back to the window
# minutes branch anchored at the (still-valid) until.
since, until = _resolve_window("2026-04-20T12:00:00Z", "2026-04-20T10:00:00Z", 30)
assert until == "2026-04-20T10:00:00Z"
assert since == "2026-04-20T09:30:00Z" # until - 30 min
def test_resolve_window_zero_or_negative_minutes_falls_back_to_default() -> None:
# Defensive: a caller might pass 0 or a negative window. Treat as unset.
for minutes in (0, -5):
since, until = _resolve_window("", "2026-04-20T10:00:00Z", minutes)
since_dt = datetime.fromisoformat(since.replace("Z", "+00:00"))
until_dt = datetime.fromisoformat(until.replace("Z", "+00:00"))
assert until_dt - since_dt == timedelta(minutes=DEFAULT_WINDOW_MINUTES)
def test_resolve_window_normalises_naive_timestamps_to_utc() -> None:
# Naive input (no timezone) must be treated as UTC. Without this, the
# subsequent astimezone(UTC) or > comparison against 'now' would raise
# on one input but not the other.
since, until = _resolve_window("2026-04-20T08:00:00", "2026-04-20T09:00:00", None)
assert since == "2026-04-20T08:00:00Z"
assert until == "2026-04-20T09:00:00Z"
def test_resolve_window_handles_mixed_aware_and_naive() -> None:
# since has offset, until is naive — must not raise a TypeError on
# the inverted-range comparison.
since, until = _resolve_window("2026-04-20T08:00:00+00:00", "2026-04-20T09:00:00", None)
assert since == "2026-04-20T08:00:00Z"
assert until == "2026-04-20T09:00:00Z"
# ---------------------------------------------------------------------------
# _summarize_commit
# ---------------------------------------------------------------------------
def test_summarize_commit_keeps_subject_only() -> None:
summarised = _summarize_commit(
{
"sha": "1234567890abcdef",
"html_url": "u",
"commit": {
"author": {"name": "A", "date": "2026-04-20T09:00:00Z"},
"committer": {"date": "2026-04-20T09:00:10Z"},
"message": "subject line\n\nbody one\nbody two",
},
}
)
assert summarised["short_sha"] == "1234567"
assert summarised["message_subject"] == "subject line"
def test_summarize_commit_handles_missing_fields() -> None:
# Defensive: real MCP responses have been observed to drop optional fields
# (e.g. committer, html_url); the summariser must never raise.
summarised = _summarize_commit({"sha": "", "commit": {}})
assert summarised["message_subject"] == ""
assert summarised["author_name"] == ""
assert summarised["short_sha"] == ""