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:
294
tests/graph/test_ci_nodes.py
Normal file
294
tests/graph/test_ci_nodes.py
Normal file
@@ -0,0 +1,294 @@
|
||||
"""Tests for graph/ci_nodes.py.
|
||||
|
||||
Written FIRST (TDD RED phase).
|
||||
|
||||
All external calls (azdo, slack, poll_until) are mocked.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from release_agent.graph.ci_nodes import notify_ci_result, poll_ci_build, trigger_ci_build
|
||||
from release_agent.models.build import BuildStatus
|
||||
from release_agent.models.pipeline import PipelineInfo
|
||||
from tests.graph.conftest import build_config, build_mock_clients
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_pipeline(pipeline_id: int = 10, name: str = "CI-build") -> dict:
|
||||
return {"id": pipeline_id, "name": name, "repo": "my-repo"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# trigger_ci_build
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTriggerCiBuild:
|
||||
"""Tests for trigger_ci_build node."""
|
||||
|
||||
async def test_triggers_pipeline_on_branch(self) -> None:
|
||||
clients = build_mock_clients()
|
||||
clients.azdo.list_build_pipelines.return_value = [
|
||||
PipelineInfo(id=10, name="CI", repo="my-repo")
|
||||
]
|
||||
clients.azdo.trigger_pipeline.return_value = {"id": 555, "state": "inProgress"}
|
||||
config = build_config(clients)
|
||||
state = {"repo_name": "my-repo", "version": "v1.0.0"}
|
||||
|
||||
result = await trigger_ci_build(state, config)
|
||||
|
||||
clients.azdo.trigger_pipeline.assert_called_once()
|
||||
assert "ci_build_id" in result
|
||||
assert result["ci_build_id"] == 555
|
||||
|
||||
async def test_returns_ci_build_id(self) -> None:
|
||||
clients = build_mock_clients()
|
||||
clients.azdo.list_build_pipelines.return_value = [
|
||||
PipelineInfo(id=20, name="build-and-test", repo="my-repo")
|
||||
]
|
||||
clients.azdo.trigger_pipeline.return_value = {"id": 999}
|
||||
config = build_config(clients)
|
||||
state = {"repo_name": "my-repo", "version": "v2.0.0"}
|
||||
|
||||
result = await trigger_ci_build(state, config)
|
||||
assert result["ci_build_id"] == 999
|
||||
|
||||
async def test_appends_error_when_no_pipelines_found(self) -> None:
|
||||
clients = build_mock_clients()
|
||||
clients.azdo.list_build_pipelines.return_value = []
|
||||
config = build_config(clients)
|
||||
state = {"repo_name": "my-repo", "version": "v1.0.0"}
|
||||
|
||||
result = await trigger_ci_build(state, config)
|
||||
|
||||
assert "errors" in result
|
||||
assert len(result["errors"]) >= 1
|
||||
|
||||
async def test_appends_error_on_trigger_failure(self) -> None:
|
||||
from release_agent.exceptions import ServiceError
|
||||
|
||||
clients = build_mock_clients()
|
||||
clients.azdo.list_build_pipelines.return_value = [
|
||||
PipelineInfo(id=10, name="CI", repo="my-repo")
|
||||
]
|
||||
clients.azdo.trigger_pipeline.side_effect = ServiceError(
|
||||
service="azdo", status_code=500, detail="Internal error"
|
||||
)
|
||||
config = build_config(clients)
|
||||
state = {"repo_name": "my-repo", "version": "v1.0.0"}
|
||||
|
||||
result = await trigger_ci_build(state, config)
|
||||
|
||||
assert "errors" in result
|
||||
|
||||
async def test_uses_main_branch_when_no_version(self) -> None:
|
||||
clients = build_mock_clients()
|
||||
clients.azdo.list_build_pipelines.return_value = [
|
||||
PipelineInfo(id=10, name="CI", repo="my-repo")
|
||||
]
|
||||
clients.azdo.trigger_pipeline.return_value = {"id": 1}
|
||||
config = build_config(clients)
|
||||
state = {"repo_name": "my-repo"}
|
||||
|
||||
result = await trigger_ci_build(state, config)
|
||||
|
||||
call_kwargs = clients.azdo.trigger_pipeline.call_args[1]
|
||||
branch = call_kwargs.get("branch", "")
|
||||
assert "main" in branch or "refs/heads" in branch
|
||||
|
||||
async def test_appends_message_on_success(self) -> None:
|
||||
clients = build_mock_clients()
|
||||
clients.azdo.list_build_pipelines.return_value = [
|
||||
PipelineInfo(id=10, name="CI", repo="my-repo")
|
||||
]
|
||||
clients.azdo.trigger_pipeline.return_value = {"id": 123}
|
||||
config = build_config(clients)
|
||||
state = {"repo_name": "my-repo", "version": "v1.0.0"}
|
||||
|
||||
result = await trigger_ci_build(state, config)
|
||||
|
||||
assert "messages" in result
|
||||
assert len(result["messages"]) >= 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# poll_ci_build
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPollCiBuild:
|
||||
"""Tests for poll_ci_build node."""
|
||||
|
||||
async def test_returns_ci_build_status_and_result_on_completion(self) -> None:
|
||||
clients = build_mock_clients()
|
||||
completed_status = BuildStatus(status="completed", result="succeeded", build_url="https://build/1")
|
||||
config = build_config(clients)
|
||||
state = {"ci_build_id": 42, "repo_name": "my-repo"}
|
||||
|
||||
with patch(
|
||||
"release_agent.graph.ci_nodes.poll_until",
|
||||
return_value=(completed_status, True),
|
||||
):
|
||||
result = await poll_ci_build(state, config)
|
||||
|
||||
assert result["ci_build_status"] == "completed"
|
||||
assert result["ci_build_result"] == "succeeded"
|
||||
|
||||
async def test_returns_build_url(self) -> None:
|
||||
clients = build_mock_clients()
|
||||
completed_status = BuildStatus(
|
||||
status="completed",
|
||||
result="succeeded",
|
||||
build_url="https://dev.azure.com/build/42",
|
||||
)
|
||||
config = build_config(clients)
|
||||
state = {"ci_build_id": 42, "repo_name": "my-repo"}
|
||||
|
||||
with patch(
|
||||
"release_agent.graph.ci_nodes.poll_until",
|
||||
return_value=(completed_status, True),
|
||||
):
|
||||
result = await poll_ci_build(state, config)
|
||||
|
||||
assert result.get("ci_build_url") == "https://dev.azure.com/build/42"
|
||||
|
||||
async def test_appends_error_on_timeout(self) -> None:
|
||||
clients = build_mock_clients()
|
||||
running_status = BuildStatus(status="inProgress", result=None, build_url=None)
|
||||
config = build_config(clients)
|
||||
state = {"ci_build_id": 42, "repo_name": "my-repo"}
|
||||
|
||||
with patch(
|
||||
"release_agent.graph.ci_nodes.poll_until",
|
||||
return_value=(running_status, False),
|
||||
):
|
||||
result = await poll_ci_build(state, config)
|
||||
|
||||
assert "errors" in result
|
||||
|
||||
async def test_appends_error_when_build_id_missing(self) -> None:
|
||||
clients = build_mock_clients()
|
||||
config = build_config(clients)
|
||||
state = {"repo_name": "my-repo"} # no ci_build_id
|
||||
|
||||
result = await poll_ci_build(state, config)
|
||||
|
||||
assert "errors" in result
|
||||
|
||||
async def test_passes_correct_build_id_to_poll_fn(self) -> None:
|
||||
clients = build_mock_clients()
|
||||
clients.azdo.get_build_status.return_value = BuildStatus(
|
||||
status="completed", result="succeeded", build_url=None
|
||||
)
|
||||
config = build_config(clients)
|
||||
state = {"ci_build_id": 77, "repo_name": "my-repo"}
|
||||
|
||||
async def fake_poll_until(*, poll_fn, is_done, interval_seconds, max_wait_seconds, sleep_fn=None):
|
||||
result = await poll_fn()
|
||||
return result, True
|
||||
|
||||
with patch("release_agent.graph.ci_nodes.poll_until", side_effect=fake_poll_until):
|
||||
await poll_ci_build(state, config)
|
||||
|
||||
clients.azdo.get_build_status.assert_called_once_with(build_id=77)
|
||||
|
||||
async def test_result_none_when_poll_returns_none(self) -> None:
|
||||
clients = build_mock_clients()
|
||||
config = build_config(clients)
|
||||
state = {"ci_build_id": 42, "repo_name": "my-repo"}
|
||||
|
||||
with patch(
|
||||
"release_agent.graph.ci_nodes.poll_until",
|
||||
return_value=(None, False),
|
||||
):
|
||||
result = await poll_ci_build(state, config)
|
||||
|
||||
assert "errors" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# notify_ci_result
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNotifyCiResult:
|
||||
"""Tests for notify_ci_result node."""
|
||||
|
||||
async def test_sends_notification_on_success(self) -> None:
|
||||
clients = build_mock_clients()
|
||||
clients.slack.send_notification.return_value = True
|
||||
config = build_config(clients)
|
||||
state = {
|
||||
"repo_name": "my-repo",
|
||||
"ci_build_status": "completed",
|
||||
"ci_build_result": "succeeded",
|
||||
"ci_build_url": "https://build/99",
|
||||
}
|
||||
|
||||
result = await notify_ci_result(state, config)
|
||||
|
||||
clients.slack.send_notification.assert_called_once()
|
||||
assert "messages" in result
|
||||
|
||||
async def test_sends_notification_on_failure(self) -> None:
|
||||
clients = build_mock_clients()
|
||||
clients.slack.send_notification.return_value = True
|
||||
config = build_config(clients)
|
||||
state = {
|
||||
"repo_name": "my-repo",
|
||||
"ci_build_status": "completed",
|
||||
"ci_build_result": "failed",
|
||||
"ci_build_url": None,
|
||||
}
|
||||
|
||||
result = await notify_ci_result(state, config)
|
||||
|
||||
clients.slack.send_notification.assert_called_once()
|
||||
|
||||
async def test_handles_slack_error_gracefully(self) -> None:
|
||||
from release_agent.exceptions import ServiceError
|
||||
|
||||
clients = build_mock_clients()
|
||||
clients.slack.send_notification.side_effect = ServiceError(
|
||||
service="slack", status_code=500, detail="Slack error"
|
||||
)
|
||||
config = build_config(clients)
|
||||
state = {
|
||||
"repo_name": "my-repo",
|
||||
"ci_build_result": "succeeded",
|
||||
"ci_build_url": None,
|
||||
}
|
||||
|
||||
result = await notify_ci_result(state, config)
|
||||
|
||||
# Should not re-raise; should append error
|
||||
assert "errors" in result
|
||||
|
||||
async def test_includes_repo_name_in_message(self) -> None:
|
||||
clients = build_mock_clients()
|
||||
clients.slack.send_notification.return_value = True
|
||||
config = build_config(clients)
|
||||
state = {
|
||||
"repo_name": "super-service",
|
||||
"ci_build_result": "succeeded",
|
||||
"ci_build_url": None,
|
||||
}
|
||||
|
||||
await notify_ci_result(state, config)
|
||||
|
||||
call_kwargs = clients.slack.send_notification.call_args[1]
|
||||
text_or_blocks = str(call_kwargs)
|
||||
assert "super-service" in text_or_blocks
|
||||
|
||||
async def test_returns_empty_dict_when_state_has_no_data(self) -> None:
|
||||
clients = build_mock_clients()
|
||||
clients.slack.send_notification.return_value = True
|
||||
config = build_config(clients)
|
||||
state = {}
|
||||
|
||||
result = await notify_ci_result(state, config)
|
||||
|
||||
# Should not crash; may return messages or empty dict
|
||||
assert isinstance(result, dict)
|
||||
Reference in New Issue
Block a user