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
605 lines
23 KiB
Python
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"] == ""
|