feat: initial commit — Billo Release Agent (LangGraph)
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.
This commit is contained in:
446
tests/api/test_internals.py
Normal file
446
tests/api/test_internals.py
Normal file
@@ -0,0 +1,446 @@
|
||||
"""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"]
|
||||
Reference in New Issue
Block a user