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.
867 lines
34 KiB
Python
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)
|