LangGraph-based release automation agent with: - PR discovery (webhook + polling) - AI code review via Claude Code CLI (subscription-based) - Auto-create Jira tickets for PRs without ticket ID - Jira ticket lifecycle management (code review -> staging -> done) - CI/CD pipeline trigger, polling, and approval gates - Slack interactive messages with approval buttons - Per-repo semantic versioning - PostgreSQL persistence (threads, staging, releases) - FastAPI API (webhooks, approvals, status, manual triggers) - Docker Compose deployment 1069 tests, 95%+ coverage.
447 lines
16 KiB
Python
447 lines
16 KiB
Python
"""Tests for internal async helper functions.
|
|
|
|
Tests _run_graph, _upsert_thread, _resume_graph, and exception handlers.
|
|
Written FIRST then verified (TDD GREEN phase for internal helpers).
|
|
"""
|
|
|
|
import json
|
|
from unittest.mock import AsyncMock, MagicMock, call, patch
|
|
|
|
import pytest
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _upsert_thread tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestUpsertThread:
|
|
@pytest.mark.asyncio
|
|
async def test_upsert_thread_executes_sql(self) -> None:
|
|
from release_agent.api.webhooks import _upsert_thread
|
|
|
|
mock_pool = MagicMock()
|
|
mock_conn = AsyncMock()
|
|
mock_cursor = AsyncMock()
|
|
|
|
mock_cursor.execute = AsyncMock()
|
|
mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor)
|
|
mock_cursor.__aexit__ = AsyncMock(return_value=False)
|
|
mock_conn.cursor = MagicMock(return_value=mock_cursor)
|
|
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
mock_conn.__aexit__ = AsyncMock(return_value=False)
|
|
mock_pool.connection = MagicMock(return_value=mock_conn)
|
|
|
|
await _upsert_thread(
|
|
mock_pool,
|
|
thread_id="t1",
|
|
thread_status="running",
|
|
state={"repo_name": "my-repo"},
|
|
)
|
|
|
|
mock_cursor.execute.assert_called_once()
|
|
args = mock_cursor.execute.call_args[0]
|
|
assert "agent_threads" in args[0]
|
|
assert args[1][0] == "t1"
|
|
assert args[1][4] == "running"
|
|
# state is JSON-encoded
|
|
state_json = json.loads(args[1][5])
|
|
assert state_json["repo_name"] == "my-repo"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upsert_thread_completed_status(self) -> None:
|
|
from release_agent.api.webhooks import _upsert_thread
|
|
|
|
mock_pool = MagicMock()
|
|
mock_conn = AsyncMock()
|
|
mock_cursor = AsyncMock()
|
|
mock_cursor.execute = AsyncMock()
|
|
mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor)
|
|
mock_cursor.__aexit__ = AsyncMock(return_value=False)
|
|
mock_conn.cursor = MagicMock(return_value=mock_cursor)
|
|
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
mock_conn.__aexit__ = AsyncMock(return_value=False)
|
|
mock_pool.connection = MagicMock(return_value=mock_conn)
|
|
|
|
await _upsert_thread(
|
|
mock_pool,
|
|
thread_id="t2",
|
|
thread_status="completed",
|
|
state={},
|
|
)
|
|
|
|
args = mock_cursor.execute.call_args[0]
|
|
assert args[1][4] == "completed"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upsert_thread_failed_status(self) -> None:
|
|
from release_agent.api.webhooks import _upsert_thread
|
|
|
|
mock_pool = MagicMock()
|
|
mock_conn = AsyncMock()
|
|
mock_cursor = AsyncMock()
|
|
mock_cursor.execute = AsyncMock()
|
|
mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor)
|
|
mock_cursor.__aexit__ = AsyncMock(return_value=False)
|
|
mock_conn.cursor = MagicMock(return_value=mock_cursor)
|
|
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
mock_conn.__aexit__ = AsyncMock(return_value=False)
|
|
mock_pool.connection = MagicMock(return_value=mock_conn)
|
|
|
|
await _upsert_thread(
|
|
mock_pool,
|
|
thread_id="t3",
|
|
thread_status="failed",
|
|
state={"errors": ["something went wrong"]},
|
|
)
|
|
|
|
args = mock_cursor.execute.call_args[0]
|
|
assert args[1][4] == "failed"
|
|
state_json = json.loads(args[1][5])
|
|
assert state_json["errors"] == ["something went wrong"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _run_graph tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestRunGraph:
|
|
@pytest.mark.asyncio
|
|
async def test_run_graph_success_upserts_completed(self) -> None:
|
|
from release_agent.api.webhooks import _run_graph
|
|
|
|
mock_graph = AsyncMock()
|
|
mock_graph.ainvoke = AsyncMock(return_value={"messages": ["done"]})
|
|
|
|
mock_pool = MagicMock()
|
|
mock_conn = AsyncMock()
|
|
mock_cursor = AsyncMock()
|
|
mock_cursor.execute = AsyncMock()
|
|
mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor)
|
|
mock_cursor.__aexit__ = AsyncMock(return_value=False)
|
|
mock_conn.cursor = MagicMock(return_value=mock_cursor)
|
|
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
mock_conn.__aexit__ = AsyncMock(return_value=False)
|
|
mock_pool.connection = MagicMock(return_value=mock_conn)
|
|
|
|
await _run_graph(
|
|
graph=mock_graph,
|
|
initial_state={"repo_name": "test"},
|
|
thread_id="t1",
|
|
tool_clients=MagicMock(),
|
|
db_pool=mock_pool,
|
|
)
|
|
|
|
# Should have been called with "running" then "completed"
|
|
calls = mock_cursor.execute.call_args_list
|
|
assert len(calls) == 2
|
|
# First call: "running", second call: "completed"
|
|
assert calls[0][0][1][4] == "running"
|
|
assert calls[1][0][1][4] == "completed"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_graph_failure_upserts_failed(self) -> None:
|
|
from release_agent.api.webhooks import _run_graph
|
|
|
|
mock_graph = AsyncMock()
|
|
mock_graph.ainvoke = AsyncMock(side_effect=RuntimeError("graph crashed"))
|
|
|
|
mock_pool = MagicMock()
|
|
mock_conn = AsyncMock()
|
|
mock_cursor = AsyncMock()
|
|
mock_cursor.execute = AsyncMock()
|
|
mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor)
|
|
mock_cursor.__aexit__ = AsyncMock(return_value=False)
|
|
mock_conn.cursor = MagicMock(return_value=mock_cursor)
|
|
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
mock_conn.__aexit__ = AsyncMock(return_value=False)
|
|
mock_pool.connection = MagicMock(return_value=mock_conn)
|
|
|
|
await _run_graph(
|
|
graph=mock_graph,
|
|
initial_state={"repo_name": "test"},
|
|
thread_id="t-fail",
|
|
tool_clients=MagicMock(),
|
|
db_pool=mock_pool,
|
|
)
|
|
|
|
calls = mock_cursor.execute.call_args_list
|
|
# First call: "running", second call: "failed"
|
|
assert calls[0][0][1][4] == "running"
|
|
assert calls[1][0][1][4] == "failed"
|
|
# State should contain errors
|
|
failed_state = json.loads(calls[1][0][1][5])
|
|
assert "errors" in failed_state
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_graph_invokes_with_correct_config(self) -> None:
|
|
from release_agent.api.webhooks import _run_graph
|
|
|
|
mock_graph = AsyncMock()
|
|
mock_graph.ainvoke = AsyncMock(return_value={})
|
|
mock_clients = MagicMock()
|
|
|
|
mock_pool = MagicMock()
|
|
mock_conn = AsyncMock()
|
|
mock_cursor = AsyncMock()
|
|
mock_cursor.execute = AsyncMock()
|
|
mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor)
|
|
mock_cursor.__aexit__ = AsyncMock(return_value=False)
|
|
mock_conn.cursor = MagicMock(return_value=mock_cursor)
|
|
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
mock_conn.__aexit__ = AsyncMock(return_value=False)
|
|
mock_pool.connection = MagicMock(return_value=mock_conn)
|
|
|
|
await _run_graph(
|
|
graph=mock_graph,
|
|
initial_state={"repo_name": "test"},
|
|
thread_id="t-config",
|
|
tool_clients=mock_clients,
|
|
db_pool=mock_pool,
|
|
)
|
|
|
|
call_args = mock_graph.ainvoke.call_args
|
|
config = call_args[1]["config"]
|
|
assert config["configurable"]["thread_id"] == "t-config"
|
|
assert config["configurable"]["clients"] is mock_clients
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _resume_graph tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestResumeGraphInternal:
|
|
@pytest.mark.asyncio
|
|
async def test_resume_graph_success(self) -> None:
|
|
from release_agent.api.approvals import _resume_graph
|
|
|
|
mock_graph = AsyncMock()
|
|
mock_graph.ainvoke = AsyncMock(return_value={"result": "ok"})
|
|
|
|
mock_pool = MagicMock()
|
|
mock_conn = AsyncMock()
|
|
mock_cursor = AsyncMock()
|
|
mock_cursor.execute = AsyncMock()
|
|
mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor)
|
|
mock_cursor.__aexit__ = AsyncMock(return_value=False)
|
|
mock_conn.cursor = MagicMock(return_value=mock_cursor)
|
|
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
mock_conn.__aexit__ = AsyncMock(return_value=False)
|
|
mock_pool.connection = MagicMock(return_value=mock_conn)
|
|
|
|
result = await _resume_graph(
|
|
graph=mock_graph,
|
|
thread_id="t1",
|
|
decision="merge",
|
|
tool_clients=MagicMock(),
|
|
db_pool=mock_pool,
|
|
)
|
|
|
|
assert result == {"result": "ok"}
|
|
mock_graph.ainvoke.assert_called_once()
|
|
# Verify the decision was passed
|
|
call_args = mock_graph.ainvoke.call_args
|
|
assert call_args[0][0]["decision"] == "merge"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resume_graph_failure_re_raises(self) -> None:
|
|
from release_agent.api.approvals import _resume_graph
|
|
|
|
mock_graph = AsyncMock()
|
|
mock_graph.ainvoke = AsyncMock(side_effect=RuntimeError("resume failed"))
|
|
|
|
mock_pool = MagicMock()
|
|
mock_conn = AsyncMock()
|
|
mock_cursor = AsyncMock()
|
|
mock_cursor.execute = AsyncMock()
|
|
mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor)
|
|
mock_cursor.__aexit__ = AsyncMock(return_value=False)
|
|
mock_conn.cursor = MagicMock(return_value=mock_cursor)
|
|
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
mock_conn.__aexit__ = AsyncMock(return_value=False)
|
|
mock_pool.connection = MagicMock(return_value=mock_conn)
|
|
|
|
with pytest.raises(RuntimeError, match="resume failed"):
|
|
await _resume_graph(
|
|
graph=mock_graph,
|
|
thread_id="t1",
|
|
decision="cancel",
|
|
tool_clients=MagicMock(),
|
|
db_pool=mock_pool,
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resume_graph_upserts_completed_on_success(self) -> None:
|
|
from release_agent.api.approvals import _resume_graph
|
|
|
|
mock_graph = AsyncMock()
|
|
mock_graph.ainvoke = AsyncMock(return_value={"messages": ["done"]})
|
|
|
|
mock_pool = MagicMock()
|
|
mock_conn = AsyncMock()
|
|
mock_cursor = AsyncMock()
|
|
mock_cursor.execute = AsyncMock()
|
|
mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor)
|
|
mock_cursor.__aexit__ = AsyncMock(return_value=False)
|
|
mock_conn.cursor = MagicMock(return_value=mock_cursor)
|
|
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
mock_conn.__aexit__ = AsyncMock(return_value=False)
|
|
mock_pool.connection = MagicMock(return_value=mock_conn)
|
|
|
|
await _resume_graph(
|
|
graph=mock_graph,
|
|
thread_id="t-success",
|
|
decision="approve",
|
|
tool_clients=MagicMock(),
|
|
db_pool=mock_pool,
|
|
)
|
|
|
|
# The last execute call should be "completed"
|
|
last_call = mock_cursor.execute.call_args_list[-1]
|
|
assert last_call[0][1][4] == "completed"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resume_graph_upserts_failed_on_exception(self) -> None:
|
|
from release_agent.api.approvals import _resume_graph
|
|
|
|
mock_graph = AsyncMock()
|
|
mock_graph.ainvoke = AsyncMock(side_effect=ValueError("bad"))
|
|
|
|
mock_pool = MagicMock()
|
|
mock_conn = AsyncMock()
|
|
mock_cursor = AsyncMock()
|
|
mock_cursor.execute = AsyncMock()
|
|
mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor)
|
|
mock_cursor.__aexit__ = AsyncMock(return_value=False)
|
|
mock_conn.cursor = MagicMock(return_value=mock_cursor)
|
|
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
mock_conn.__aexit__ = AsyncMock(return_value=False)
|
|
mock_pool.connection = MagicMock(return_value=mock_conn)
|
|
|
|
with pytest.raises(ValueError):
|
|
await _resume_graph(
|
|
graph=mock_graph,
|
|
thread_id="t-fail",
|
|
decision="skip",
|
|
tool_clients=MagicMock(),
|
|
db_pool=mock_pool,
|
|
)
|
|
|
|
last_call = mock_cursor.execute.call_args_list[-1]
|
|
assert last_call[0][1][4] == "failed"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# run_graph_in_background tests (main.py)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestRunGraphInBackground:
|
|
@pytest.mark.asyncio
|
|
async def test_success_with_db_pool(self) -> None:
|
|
from release_agent.main import run_graph_in_background
|
|
|
|
mock_graph = AsyncMock()
|
|
mock_graph.ainvoke = AsyncMock(return_value={"done": True})
|
|
|
|
mock_pool = MagicMock()
|
|
mock_conn = AsyncMock()
|
|
mock_cursor = AsyncMock()
|
|
mock_cursor.execute = AsyncMock()
|
|
mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor)
|
|
mock_cursor.__aexit__ = AsyncMock(return_value=False)
|
|
mock_conn.cursor = MagicMock(return_value=mock_cursor)
|
|
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
mock_conn.__aexit__ = AsyncMock(return_value=False)
|
|
mock_pool.connection = MagicMock(return_value=mock_conn)
|
|
|
|
await run_graph_in_background(
|
|
graph=mock_graph,
|
|
initial_state={"repo_name": "test"},
|
|
thread_id="t-bg",
|
|
db_pool=mock_pool,
|
|
)
|
|
|
|
calls = mock_cursor.execute.call_args_list
|
|
assert calls[0][0][1][4] == "running"
|
|
assert calls[1][0][1][4] == "completed"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_failure_with_db_pool(self) -> None:
|
|
from release_agent.main import run_graph_in_background
|
|
|
|
mock_graph = AsyncMock()
|
|
mock_graph.ainvoke = AsyncMock(side_effect=RuntimeError("bg failed"))
|
|
|
|
mock_pool = MagicMock()
|
|
mock_conn = AsyncMock()
|
|
mock_cursor = AsyncMock()
|
|
mock_cursor.execute = AsyncMock()
|
|
mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor)
|
|
mock_cursor.__aexit__ = AsyncMock(return_value=False)
|
|
mock_conn.cursor = MagicMock(return_value=mock_cursor)
|
|
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
mock_conn.__aexit__ = AsyncMock(return_value=False)
|
|
mock_pool.connection = MagicMock(return_value=mock_conn)
|
|
|
|
await run_graph_in_background(
|
|
graph=mock_graph,
|
|
initial_state={},
|
|
thread_id="t-bg-fail",
|
|
db_pool=mock_pool,
|
|
)
|
|
|
|
last_call = mock_cursor.execute.call_args_list[-1]
|
|
assert last_call[0][1][4] == "failed"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_success_without_db_pool(self) -> None:
|
|
"""run_graph_in_background works even without a db_pool."""
|
|
from release_agent.main import run_graph_in_background
|
|
|
|
mock_graph = AsyncMock()
|
|
mock_graph.ainvoke = AsyncMock(return_value={})
|
|
|
|
# Should not raise even with no db_pool
|
|
await run_graph_in_background(
|
|
graph=mock_graph,
|
|
initial_state={},
|
|
thread_id="t-no-pool",
|
|
db_pool=None,
|
|
)
|
|
|
|
mock_graph.ainvoke.assert_called_once()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Exception handler tests (main.py)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestExceptionHandlerFunctions:
|
|
@pytest.mark.asyncio
|
|
async def test_release_agent_error_handler_returns_500(self) -> None:
|
|
from release_agent.main import _release_agent_error_handler
|
|
from release_agent.exceptions import ServiceError
|
|
|
|
request = MagicMock()
|
|
exc = ServiceError(service="azdo", status_code=503, detail="unavailable")
|
|
|
|
response = await _release_agent_error_handler(request, exc)
|
|
|
|
assert response.status_code == 500
|
|
body = json.loads(response.body)
|
|
assert body["error"] == "ServiceError"
|
|
assert "unavailable" in body["detail"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generic_error_handler_returns_500(self) -> None:
|
|
from release_agent.main import _generic_error_handler
|
|
|
|
request = MagicMock()
|
|
exc = ValueError("something generic")
|
|
|
|
response = await _generic_error_handler(request, exc)
|
|
|
|
assert response.status_code == 500
|
|
body = json.loads(response.body)
|
|
assert body["error"] == "InternalServerError"
|
|
assert "An unexpected error occurred" in body["detail"]
|