Files
billo-release-agent/tests/graph/test_release.py
Yaojia Wang f5c2733cfb 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.
2026-03-24 17:38:23 +01:00

867 lines
34 KiB
Python

"""Tests for graph/release.py node functions. Written FIRST (TDD RED phase).
Each node is an async function (state, config) -> dict.
Tests call nodes directly — no graph compilation required.
"""
from datetime import date
from unittest.mock import AsyncMock, patch
import pytest
from release_agent.graph.dependencies import JsonFileStagingStore
from release_agent.graph.release import (
approve_stage,
archive_release,
check_release_approvals,
create_release_pr,
interrupt_confirm_approve,
interrupt_confirm_merge_release,
interrupt_confirm_release,
interrupt_confirm_trigger,
list_pipelines,
load_staging,
merge_release_pr,
move_tickets_to_done,
send_slack_notification,
trigger_pipelines,
build_release_graph,
)
from release_agent.models.pipeline import PipelineInfo, ReleasePipelineStage
from release_agent.models.release import StagingRelease
from release_agent.models.ticket import TicketEntry
from tests.graph.conftest import build_config, build_mock_clients
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_ticket(ticket_id: str = "ALLPOST-1") -> TicketEntry:
return TicketEntry(
id=ticket_id,
summary="Fix something",
pr_id="42",
pr_url="https://dev.azure.com/org/proj/_git/repo/pullrequest/42",
pr_title="Fix: something",
branch=f"feature/{ticket_id}-fix",
merged_at=date(2025, 1, 15),
)
def _make_staging(
*,
repo: str = "my-repo",
version: str = "v1.0.0",
tickets: list | None = None,
) -> StagingRelease:
t = tickets if tickets is not None else [_make_ticket()]
return StagingRelease(
version=version,
repo=repo,
started_at=date(2025, 1, 1),
tickets=t,
)
def _staging_dict(staging: StagingRelease) -> dict:
return staging.model_dump(mode="json")
# ---------------------------------------------------------------------------
# load_staging
# ---------------------------------------------------------------------------
class TestLoadStaging:
async def test_loads_staging_from_store(self, tmp_path) -> None:
staging_store = JsonFileStagingStore(directory=tmp_path)
staging = _make_staging()
await staging_store.save(staging)
clients = build_mock_clients()
config = build_config(clients, staging_store=staging_store)
state = {"repo_name": "my-repo"}
result = await load_staging(state, config)
assert "staging" in result
assert result["staging"]["version"] == "v1.0.0"
async def test_returns_none_when_no_staging(self, tmp_path) -> None:
staging_store = JsonFileStagingStore(directory=tmp_path)
clients = build_mock_clients()
config = build_config(clients, staging_store=staging_store)
state = {"repo_name": "nonexistent"}
result = await load_staging(state, config)
assert result.get("staging") is None
async def test_staging_includes_tickets(self, tmp_path) -> None:
staging_store = JsonFileStagingStore(directory=tmp_path)
staging = _make_staging(tickets=[_make_ticket("BILL-10"), _make_ticket("BILL-11")])
await staging_store.save(staging)
clients = build_mock_clients()
config = build_config(clients, staging_store=staging_store)
state = {"repo_name": "my-repo"}
result = await load_staging(state, config)
assert len(result["staging"]["tickets"]) == 2
# ---------------------------------------------------------------------------
# interrupt_confirm_release
# ---------------------------------------------------------------------------
class TestInterruptConfirmRelease:
async def test_calls_interrupt_with_staging_summary(self) -> None:
config = build_config()
staging = _make_staging()
state = {
"repo_name": "my-repo",
"staging": _staging_dict(staging),
}
with patch("release_agent.graph.release.interrupt") as mock_interrupt:
mock_interrupt.return_value = "confirm"
await interrupt_confirm_release(state, config)
mock_interrupt.assert_called_once()
call_arg = mock_interrupt.call_args[0][0]
assert isinstance(call_arg, str)
async def test_interrupt_contains_version_and_repo(self) -> None:
config = build_config()
staging = _make_staging(version="v2.5.0", repo="backend")
state = {
"repo_name": "backend",
"staging": _staging_dict(staging),
}
with patch("release_agent.graph.release.interrupt") as mock_interrupt:
mock_interrupt.return_value = "confirm"
await interrupt_confirm_release(state, config)
call_arg = mock_interrupt.call_args[0][0]
assert "v2.5.0" in call_arg or "backend" in call_arg
# ---------------------------------------------------------------------------
# create_release_pr
# ---------------------------------------------------------------------------
class TestCreateReleasePr:
async def test_calls_azdo_create_pr(self) -> None:
clients = build_mock_clients()
clients.azdo.create_pr = AsyncMock(return_value={
"pullRequestId": 99,
"lastMergeSourceCommit": {"commitId": "deadbeef"},
})
config = build_config(clients)
staging = _make_staging(version="v1.2.0")
state = {
"repo_name": "my-repo",
"version": "v1.2.0",
"staging": _staging_dict(staging),
}
result = await create_release_pr(state, config)
clients.azdo.create_pr.assert_called_once()
call_kwargs = clients.azdo.create_pr.call_args.kwargs
assert call_kwargs["repo"] == "my-repo"
async def test_sets_release_pr_id(self) -> None:
clients = build_mock_clients()
clients.azdo.create_pr = AsyncMock(return_value={
"pullRequestId": 77,
"lastMergeSourceCommit": {"commitId": "cafe1234"},
})
config = build_config(clients)
staging = _make_staging(version="v1.0.3")
state = {
"repo_name": "my-repo",
"version": "v1.0.3",
"staging": _staging_dict(staging),
}
result = await create_release_pr(state, config)
assert result["release_pr_id"] == "77"
async def test_sets_release_pr_commit(self) -> None:
clients = build_mock_clients()
clients.azdo.create_pr = AsyncMock(return_value={
"pullRequestId": 77,
"lastMergeSourceCommit": {"commitId": "cafe1234"},
})
config = build_config(clients)
staging = _make_staging()
state = {
"repo_name": "my-repo",
"version": "v1.0.0",
"staging": _staging_dict(staging),
}
result = await create_release_pr(state, config)
assert result["release_pr_commit"] == "cafe1234"
async def test_re_raises_on_service_error(self) -> None:
from release_agent.exceptions import ServiceError
clients = build_mock_clients()
clients.azdo.create_pr = AsyncMock(side_effect=ServiceError(
service="azdo", status_code=422, detail="Invalid branch"
))
config = build_config(clients)
staging = _make_staging()
state = {
"repo_name": "my-repo",
"version": "v1.0.0",
"staging": _staging_dict(staging),
}
with pytest.raises(ServiceError):
await create_release_pr(state, config)
# ---------------------------------------------------------------------------
# interrupt_confirm_merge_release
# ---------------------------------------------------------------------------
class TestInterruptConfirmMergeRelease:
async def test_calls_interrupt_with_pr_info(self) -> None:
config = build_config()
state = {
"release_pr_id": "99",
"version": "v1.0.0",
"repo_name": "my-repo",
}
with patch("release_agent.graph.release.interrupt") as mock_interrupt:
mock_interrupt.return_value = "confirm"
await interrupt_confirm_merge_release(state, config)
mock_interrupt.assert_called_once()
call_arg = mock_interrupt.call_args[0][0]
assert isinstance(call_arg, str)
assert len(call_arg) > 0
# ---------------------------------------------------------------------------
# merge_release_pr
# ---------------------------------------------------------------------------
class TestMergeReleasePr:
async def test_calls_azdo_merge_pr(self) -> None:
clients = build_mock_clients()
clients.azdo.merge_pr = AsyncMock(return_value=True)
config = build_config(clients)
state = {
"release_pr_id": "99",
"release_pr_commit": "abc123",
}
await merge_release_pr(state, config)
clients.azdo.merge_pr.assert_called_once_with(
pr_id=99, last_merge_source_commit="abc123"
)
async def test_re_raises_on_service_error(self) -> None:
from release_agent.exceptions import ServiceError
clients = build_mock_clients()
clients.azdo.merge_pr = AsyncMock(side_effect=ServiceError(
service="azdo", status_code=409, detail="Conflict"
))
config = build_config(clients)
state = {"release_pr_id": "99", "release_pr_commit": "abc"}
with pytest.raises(ServiceError):
await merge_release_pr(state, config)
# ---------------------------------------------------------------------------
# move_tickets_to_done
# ---------------------------------------------------------------------------
class TestMoveTicketsToDone:
async def test_transitions_all_tickets(self) -> None:
clients = build_mock_clients()
clients.jira.transition_issue = AsyncMock(return_value=True)
config = build_config(clients)
staging = _make_staging(tickets=[_make_ticket("BILL-1"), _make_ticket("BILL-2")])
state = {"staging": _staging_dict(staging)}
await move_tickets_to_done(state, config)
assert clients.jira.transition_issue.call_count == 2
async def test_calls_transition_with_done_name(self) -> None:
clients = build_mock_clients()
clients.jira.transition_issue = AsyncMock(return_value=True)
config = build_config(clients)
staging = _make_staging(tickets=[_make_ticket("BILL-1")])
state = {"staging": _staging_dict(staging)}
await move_tickets_to_done(state, config)
call_args = clients.jira.transition_issue.call_args_list[0]
ticket_id, transition = call_args[0]
assert ticket_id == "BILL-1"
assert "done" in transition.lower() or "released" in transition.lower()
async def test_appends_error_on_jira_failure(self) -> None:
from release_agent.exceptions import ServiceError
clients = build_mock_clients()
clients.jira.transition_issue = AsyncMock(side_effect=ServiceError(
service="jira", status_code=500, detail="Error"
))
config = build_config(clients)
staging = _make_staging(tickets=[_make_ticket()])
state = {"staging": _staging_dict(staging)}
result = await move_tickets_to_done(state, config)
assert "errors" in result
async def test_empty_tickets_no_calls(self) -> None:
clients = build_mock_clients()
clients.jira.transition_issue = AsyncMock()
config = build_config(clients)
staging = _make_staging(tickets=[])
state = {"staging": _staging_dict(staging)}
await move_tickets_to_done(state, config)
clients.jira.transition_issue.assert_not_called()
# ---------------------------------------------------------------------------
# send_slack_notification
# ---------------------------------------------------------------------------
class TestSendSlackNotification:
async def test_calls_slack_send_release_notification(self) -> None:
clients = build_mock_clients()
clients.slack.send_release_notification = AsyncMock(return_value=True)
config = build_config(clients)
staging = _make_staging()
state = {
"repo_name": "my-repo",
"version": "v1.0.0",
"staging": _staging_dict(staging),
}
result = await send_slack_notification(state, config)
clients.slack.send_release_notification.assert_called_once()
async def test_appends_error_on_slack_failure(self) -> None:
from release_agent.exceptions import ServiceError
clients = build_mock_clients()
clients.slack.send_release_notification = AsyncMock(side_effect=ServiceError(
service="slack", status_code=500, detail="Webhook error"
))
config = build_config(clients)
staging = _make_staging()
state = {
"repo_name": "my-repo",
"version": "v1.0.0",
"staging": _staging_dict(staging),
}
result = await send_slack_notification(state, config)
assert "errors" in result
# ---------------------------------------------------------------------------
# archive_release
# ---------------------------------------------------------------------------
class TestArchiveRelease:
async def test_archives_staging_to_store(self, tmp_path) -> None:
staging_store = JsonFileStagingStore(directory=tmp_path)
staging = _make_staging()
await staging_store.save(staging)
clients = build_mock_clients()
config = build_config(clients, staging_store=staging_store)
state = {
"repo_name": "my-repo",
"staging": _staging_dict(staging),
}
await archive_release(state, config)
# Staging should be gone now
assert await staging_store.load("my-repo") is None
async def test_archive_file_created_in_store(self, tmp_path) -> None:
staging_store = JsonFileStagingStore(directory=tmp_path)
staging = _make_staging(version="v3.0.0")
await staging_store.save(staging)
clients = build_mock_clients()
config = build_config(clients, staging_store=staging_store)
state = {
"repo_name": "my-repo",
"staging": _staging_dict(staging),
}
await archive_release(state, config)
versions = await staging_store.list_versions("my-repo")
assert "v3.0.0" in versions
# ---------------------------------------------------------------------------
# list_pipelines
# ---------------------------------------------------------------------------
class TestListPipelines:
async def test_fetches_pipelines_from_azdo(self) -> None:
clients = build_mock_clients()
pipelines = [PipelineInfo(id=1, name="build", repo="my-repo")]
clients.azdo.list_build_pipelines = AsyncMock(return_value=pipelines)
config = build_config(clients)
state = {"repo_name": "my-repo"}
result = await list_pipelines(state, config)
clients.azdo.list_build_pipelines.assert_called_once_with(repo="my-repo")
assert "pipelines" in result
assert len(result["pipelines"]) == 1
async def test_stores_pipelines_as_list_of_dicts(self) -> None:
clients = build_mock_clients()
pipelines = [
PipelineInfo(id=1, name="build", repo="my-repo"),
PipelineInfo(id=2, name="deploy", repo="my-repo"),
]
clients.azdo.list_build_pipelines = AsyncMock(return_value=pipelines)
config = build_config(clients)
state = {"repo_name": "my-repo"}
result = await list_pipelines(state, config)
assert len(result["pipelines"]) == 2
assert result["pipelines"][0]["id"] == 1
async def test_empty_pipelines_stored_as_empty_list(self) -> None:
clients = build_mock_clients()
clients.azdo.list_build_pipelines = AsyncMock(return_value=[])
config = build_config(clients)
state = {"repo_name": "my-repo"}
result = await list_pipelines(state, config)
assert result["pipelines"] == []
async def test_appends_error_on_service_failure(self) -> None:
from release_agent.exceptions import ServiceError
clients = build_mock_clients()
clients.azdo.list_build_pipelines = AsyncMock(side_effect=ServiceError(
service="azdo", status_code=500, detail="Error"
))
config = build_config(clients)
state = {"repo_name": "my-repo"}
result = await list_pipelines(state, config)
assert "errors" in result
# ---------------------------------------------------------------------------
# interrupt_confirm_trigger
# ---------------------------------------------------------------------------
class TestInterruptConfirmTrigger:
async def test_calls_interrupt_with_pipelines_summary(self) -> None:
config = build_config()
state = {
"repo_name": "my-repo",
"version": "v1.0.0",
"pipelines": [{"id": 1, "name": "build", "repo": "my-repo"}],
}
with patch("release_agent.graph.release.interrupt") as mock_interrupt:
mock_interrupt.return_value = "confirm"
await interrupt_confirm_trigger(state, config)
mock_interrupt.assert_called_once()
call_arg = mock_interrupt.call_args[0][0]
assert isinstance(call_arg, str)
# ---------------------------------------------------------------------------
# trigger_pipelines
# ---------------------------------------------------------------------------
class TestTriggerPipelines:
async def test_triggers_each_pipeline(self) -> None:
clients = build_mock_clients()
clients.azdo.trigger_pipeline = AsyncMock(return_value={"id": 1001})
config = build_config(clients)
state = {
"repo_name": "my-repo",
"version": "v1.0.0",
"pipelines": [
{"id": 1, "name": "build", "repo": "my-repo"},
{"id": 2, "name": "deploy", "repo": "my-repo"},
],
}
result = await trigger_pipelines(state, config)
assert clients.azdo.trigger_pipeline.call_count == 2
assert "triggered_builds" in result
assert len(result["triggered_builds"]) == 2
async def test_no_pipelines_no_calls(self) -> None:
clients = build_mock_clients()
clients.azdo.trigger_pipeline = AsyncMock()
config = build_config(clients)
state = {
"repo_name": "my-repo",
"version": "v1.0.0",
"pipelines": [],
}
result = await trigger_pipelines(state, config)
clients.azdo.trigger_pipeline.assert_not_called()
assert result["triggered_builds"] == []
async def test_appends_error_on_trigger_failure(self) -> None:
from release_agent.exceptions import ServiceError
clients = build_mock_clients()
clients.azdo.trigger_pipeline = AsyncMock(side_effect=ServiceError(
service="azdo", status_code=500, detail="Error"
))
config = build_config(clients)
state = {
"repo_name": "my-repo",
"version": "v1.0.0",
"pipelines": [{"id": 1, "name": "build", "repo": "my-repo"}],
}
result = await trigger_pipelines(state, config)
assert "errors" in result
# ---------------------------------------------------------------------------
# check_release_approvals
# ---------------------------------------------------------------------------
class TestCheckReleaseApprovals:
async def test_fetches_pending_approvals_from_builds(self) -> None:
clients = build_mock_clients()
clients.azdo.get_build_status = AsyncMock(return_value="completed")
config = build_config(clients)
state = {
"triggered_builds": [{"id": 1001}],
}
result = await check_release_approvals(state, config)
assert "pending_approvals" in result
async def test_empty_builds_means_no_approvals(self) -> None:
clients = build_mock_clients()
config = build_config(clients)
state = {"triggered_builds": []}
result = await check_release_approvals(state, config)
assert result["pending_approvals"] == []
async def test_appends_error_on_failure(self) -> None:
from release_agent.exceptions import ServiceError
clients = build_mock_clients()
clients.azdo.get_build_status = AsyncMock(side_effect=ServiceError(
service="azdo", status_code=500, detail="Error"
))
config = build_config(clients)
state = {"triggered_builds": [{"id": 1001}]}
result = await check_release_approvals(state, config)
assert "errors" in result
# ---------------------------------------------------------------------------
# interrupt_confirm_approve
# ---------------------------------------------------------------------------
class TestInterruptConfirmApprove:
async def test_calls_interrupt_with_approvals_summary(self) -> None:
config = build_config()
state = {
"pending_approvals": [{"approval_id": "aaa", "stage_name": "Production"}],
"version": "v1.0.0",
}
with patch("release_agent.graph.release.interrupt") as mock_interrupt:
mock_interrupt.return_value = "confirm"
await interrupt_confirm_approve(state, config)
mock_interrupt.assert_called_once()
# ---------------------------------------------------------------------------
# approve_stage
# ---------------------------------------------------------------------------
class TestApproveStage:
async def test_approves_each_pending_approval(self) -> None:
clients = build_mock_clients()
clients.azdo.approve_release = AsyncMock(return_value={"status": "approved"})
config = build_config(clients)
state = {
"pending_approvals": [
{"approval_id": "aaa"},
{"approval_id": "bbb"},
],
}
result = await approve_stage(state, config)
assert clients.azdo.approve_release.call_count == 2
async def test_no_approvals_no_calls(self) -> None:
clients = build_mock_clients()
clients.azdo.approve_release = AsyncMock()
config = build_config(clients)
state = {"pending_approvals": []}
await approve_stage(state, config)
clients.azdo.approve_release.assert_not_called()
async def test_appends_error_on_failure(self) -> None:
from release_agent.exceptions import ServiceError
clients = build_mock_clients()
clients.azdo.approve_release = AsyncMock(side_effect=ServiceError(
service="azdo", status_code=500, detail="Error"
))
config = build_config(clients)
state = {"pending_approvals": [{"approval_id": "aaa"}]}
result = await approve_stage(state, config)
assert "errors" in result
# ---------------------------------------------------------------------------
# build_release_graph
# ---------------------------------------------------------------------------
class TestBuildReleaseGraph:
def test_returns_compiled_graph(self) -> None:
graph = build_release_graph()
assert graph is not None
def test_graph_includes_trigger_ci_build_main_node(self) -> None:
graph = build_release_graph()
graph_nodes = graph.get_graph().nodes
assert "trigger_ci_build_main" in graph_nodes
def test_graph_includes_poll_ci_build_main_node(self) -> None:
graph = build_release_graph()
graph_nodes = graph.get_graph().nodes
assert "poll_ci_build_main" in graph_nodes
def test_graph_includes_wait_for_cd_release_node(self) -> None:
graph = build_release_graph()
graph_nodes = graph.get_graph().nodes
assert "wait_for_cd_release" in graph_nodes
def test_graph_includes_poll_release_approvals_node(self) -> None:
graph = build_release_graph()
graph_nodes = graph.get_graph().nodes
assert "poll_release_approvals" in graph_nodes
def test_graph_includes_interrupt_sandbox_approval_node(self) -> None:
graph = build_release_graph()
graph_nodes = graph.get_graph().nodes
assert "interrupt_sandbox_approval" in graph_nodes
def test_graph_includes_interrupt_prod_approval_node(self) -> None:
graph = build_release_graph()
graph_nodes = graph.get_graph().nodes
assert "interrupt_prod_approval" in graph_nodes
def test_graph_includes_execute_sandbox_approval_node(self) -> None:
graph = build_release_graph()
graph_nodes = graph.get_graph().nodes
assert "execute_sandbox_approval" in graph_nodes
def test_graph_includes_execute_prod_approval_node(self) -> None:
graph = build_release_graph()
graph_nodes = graph.get_graph().nodes
assert "execute_prod_approval" in graph_nodes
def test_graph_includes_notify_ci_failure_node(self) -> None:
graph = build_release_graph()
graph_nodes = graph.get_graph().nodes
assert "notify_ci_failure" in graph_nodes
# ---------------------------------------------------------------------------
# New release graph node: wait_for_cd_release
# ---------------------------------------------------------------------------
class TestWaitForCdRelease:
"""Tests for wait_for_cd_release node."""
async def test_sets_release_id_when_found(self) -> None:
from release_agent.graph.release import wait_for_cd_release
clients = build_mock_clients()
clients.azdo.get_latest_release.return_value = {"id": 100, "name": "Release-100"}
config = build_config(clients)
state = {"release_definition_id": 5, "repo_name": "my-repo"}
result = await wait_for_cd_release(state, config)
assert "release_id" in result
assert result["release_id"] == 100
async def test_appends_error_when_no_release(self) -> None:
from release_agent.graph.release import wait_for_cd_release
clients = build_mock_clients()
clients.azdo.get_latest_release.return_value = {}
config = build_config(clients)
state = {"release_definition_id": 5, "repo_name": "my-repo"}
result = await wait_for_cd_release(state, config)
assert "errors" in result
async def test_works_without_release_definition_id(self) -> None:
from release_agent.graph.release import wait_for_cd_release
clients = build_mock_clients()
config = build_config(clients)
state = {"repo_name": "my-repo"}
result = await wait_for_cd_release(state, config)
assert isinstance(result, dict)
# ---------------------------------------------------------------------------
# New release graph node: poll_release_approvals
# ---------------------------------------------------------------------------
class TestPollReleaseApprovals:
"""Tests for poll_release_approvals node."""
async def test_sets_pending_approvals_from_azdo(self) -> None:
from release_agent.graph.release import poll_release_approvals
from release_agent.models.build import ApprovalRecord
clients = build_mock_clients()
clients.azdo.get_release_approvals.return_value = [
ApprovalRecord(approval_id="a1", stage_name="Sandbox", status="pending", release_id=10),
]
config = build_config(clients)
state = {"release_id": 10}
result = await poll_release_approvals(state, config)
assert "pending_approvals" in result
assert len(result["pending_approvals"]) == 1
async def test_returns_empty_list_when_no_approvals(self) -> None:
from release_agent.graph.release import poll_release_approvals
clients = build_mock_clients()
clients.azdo.get_release_approvals.return_value = []
config = build_config(clients)
state = {"release_id": 10}
result = await poll_release_approvals(state, config)
assert result.get("pending_approvals") == []
async def test_appends_error_on_failure(self) -> None:
from release_agent.exceptions import ServiceError
from release_agent.graph.release import poll_release_approvals
clients = build_mock_clients()
clients.azdo.get_release_approvals.side_effect = ServiceError(
service="azdo", status_code=500, detail="error"
)
config = build_config(clients)
state = {"release_id": 10}
result = await poll_release_approvals(state, config)
assert "errors" in result
# ---------------------------------------------------------------------------
# New release graph node: interrupt_sandbox_approval
# ---------------------------------------------------------------------------
class TestInterruptSandboxApproval:
async def test_calls_interrupt(self) -> None:
from release_agent.graph.release import interrupt_sandbox_approval
config = build_config()
state = {
"pending_approvals": [{"approval_id": "x", "stage_name": "Sandbox"}],
"version": "v1.0.0",
}
with patch("release_agent.graph.release.interrupt") as mock_interrupt:
mock_interrupt.return_value = "confirm"
await interrupt_sandbox_approval(state, config)
mock_interrupt.assert_called_once()
async def test_sets_current_stage_to_sandbox_pending(self) -> None:
from release_agent.graph.release import interrupt_sandbox_approval
config = build_config()
state = {
"pending_approvals": [{"approval_id": "x", "stage_name": "Sandbox"}],
}
with patch("release_agent.graph.release.interrupt", return_value="yes"):
result = await interrupt_sandbox_approval(state, config)
assert result.get("current_stage") == "sandbox_pending"
# ---------------------------------------------------------------------------
# New release graph node: interrupt_prod_approval
# ---------------------------------------------------------------------------
class TestInterruptProdApproval:
async def test_calls_interrupt(self) -> None:
from release_agent.graph.release import interrupt_prod_approval
config = build_config()
state = {
"pending_approvals": [{"approval_id": "y", "stage_name": "Production"}],
"version": "v1.0.0",
}
with patch("release_agent.graph.release.interrupt") as mock_interrupt:
mock_interrupt.return_value = "confirm"
await interrupt_prod_approval(state, config)
mock_interrupt.assert_called_once()
async def test_sets_current_stage_to_prod_pending(self) -> None:
from release_agent.graph.release import interrupt_prod_approval
config = build_config()
state = {
"pending_approvals": [{"approval_id": "y", "stage_name": "Production"}],
}
with patch("release_agent.graph.release.interrupt", return_value="yes"):
result = await interrupt_prod_approval(state, config)
assert result.get("current_stage") == "prod_pending"
# ---------------------------------------------------------------------------
# New release graph node: execute_sandbox_approval
# ---------------------------------------------------------------------------
class TestExecuteSandboxApproval:
async def test_approves_sandbox_approvals(self) -> None:
from release_agent.graph.release import execute_sandbox_approval
clients = build_mock_clients()
clients.azdo.approve_release.return_value = {"status": "approved"}
config = build_config(clients)
state = {
"pending_approvals": [{"approval_id": "sb1", "stage_name": "Sandbox"}],
}
result = await execute_sandbox_approval(state, config)
clients.azdo.approve_release.assert_called()
async def test_returns_empty_dict_on_success(self) -> None:
from release_agent.graph.release import execute_sandbox_approval
clients = build_mock_clients()
clients.azdo.approve_release.return_value = {"status": "approved"}
config = build_config(clients)
state = {"pending_approvals": [{"approval_id": "sb1"}]}
result = await execute_sandbox_approval(state, config)
assert "errors" not in result or result["errors"] == []
# ---------------------------------------------------------------------------
# New release graph node: execute_prod_approval
# ---------------------------------------------------------------------------
class TestExecuteProdApproval:
async def test_approves_prod_approvals(self) -> None:
from release_agent.graph.release import execute_prod_approval
clients = build_mock_clients()
clients.azdo.approve_release.return_value = {"status": "approved"}
config = build_config(clients)
state = {
"pending_approvals": [{"approval_id": "pd1", "stage_name": "Production"}],
}
result = await execute_prod_approval(state, config)
clients.azdo.approve_release.assert_called()
# ---------------------------------------------------------------------------
# New release graph node: notify_ci_failure
# ---------------------------------------------------------------------------
class TestNotifyCiFailure:
async def test_sends_slack_notification(self) -> None:
from release_agent.graph.release import notify_ci_failure
clients = build_mock_clients()
clients.slack.send_notification.return_value = True
config = build_config(clients)
state = {
"repo_name": "my-repo",
"ci_build_result": "failed",
"ci_build_url": "https://build/1",
}
result = await notify_ci_failure(state, config)
clients.slack.send_notification.assert_called_once()
async def test_appends_message_on_success(self) -> None:
from release_agent.graph.release import notify_ci_failure
clients = build_mock_clients()
clients.slack.send_notification.return_value = True
config = build_config(clients)
state = {"repo_name": "my-repo", "ci_build_result": "failed"}
result = await notify_ci_failure(state, config)
assert "messages" in result or isinstance(result, dict)