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:
Yaojia Wang
2026-03-24 17:38:23 +01:00
commit f5c2733cfb
104 changed files with 19721 additions and 0 deletions

View 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)