Files
billo-release-agent/tests/graph/test_routing.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

303 lines
11 KiB
Python

"""Tests for graph/routing.py. Written FIRST (TDD RED phase).
All routing functions are pure — they take a state dict and return a string.
Every branch is tested, including missing state fields (defaults to falsy).
"""
import pytest
from release_agent.graph.routing import (
has_pending_approvals,
has_pipelines,
has_ticket,
is_pr_already_merged,
is_review_approved,
route_after_fetch,
route_approval_stage,
route_ci_result,
should_continue_to_release,
)
# ---------------------------------------------------------------------------
# is_pr_already_merged
# ---------------------------------------------------------------------------
class TestIsPrAlreadyMerged:
def test_returns_merged_when_true(self) -> None:
state = {"pr_already_merged": True}
assert is_pr_already_merged(state) == "merged"
def test_returns_active_when_false(self) -> None:
state = {"pr_already_merged": False}
assert is_pr_already_merged(state) == "active"
def test_returns_active_when_field_missing(self) -> None:
state = {}
assert is_pr_already_merged(state) == "active"
def test_returns_active_when_none(self) -> None:
state = {"pr_already_merged": None}
assert is_pr_already_merged(state) == "active"
# ---------------------------------------------------------------------------
# is_review_approved
# ---------------------------------------------------------------------------
class TestIsReviewApproved:
def test_returns_approve_when_true(self) -> None:
state = {"review_approved": True}
assert is_review_approved(state) == "approve"
def test_returns_request_changes_when_false(self) -> None:
state = {"review_approved": False}
assert is_review_approved(state) == "request_changes"
def test_returns_request_changes_when_field_missing(self) -> None:
state = {}
assert is_review_approved(state) == "request_changes"
def test_returns_request_changes_when_none(self) -> None:
state = {"review_approved": None}
assert is_review_approved(state) == "request_changes"
# ---------------------------------------------------------------------------
# has_ticket
# ---------------------------------------------------------------------------
class TestHasTicket:
def test_returns_yes_when_true(self) -> None:
state = {"has_ticket": True}
assert has_ticket(state) == "yes"
def test_returns_no_when_false(self) -> None:
state = {"has_ticket": False}
assert has_ticket(state) == "no"
def test_returns_no_when_field_missing(self) -> None:
state = {}
assert has_ticket(state) == "no"
def test_returns_no_when_none(self) -> None:
state = {"has_ticket": None}
assert has_ticket(state) == "no"
# ---------------------------------------------------------------------------
# should_continue_to_release
# ---------------------------------------------------------------------------
class TestShouldContinueToRelease:
def test_returns_yes_when_true(self) -> None:
state = {"continue_to_release": True}
assert should_continue_to_release(state) == "yes"
def test_returns_no_when_false(self) -> None:
state = {"continue_to_release": False}
assert should_continue_to_release(state) == "no"
def test_returns_no_when_field_missing(self) -> None:
state = {}
assert should_continue_to_release(state) == "no"
def test_returns_no_when_none(self) -> None:
state = {"continue_to_release": None}
assert should_continue_to_release(state) == "no"
# ---------------------------------------------------------------------------
# has_pipelines
# ---------------------------------------------------------------------------
class TestHasPipelines:
def test_returns_yes_when_non_empty_list(self) -> None:
state = {"pipelines": [{"id": 1}]}
assert has_pipelines(state) == "yes"
def test_returns_no_when_empty_list(self) -> None:
state = {"pipelines": []}
assert has_pipelines(state) == "no"
def test_returns_no_when_field_missing(self) -> None:
state = {}
assert has_pipelines(state) == "no"
def test_returns_no_when_none(self) -> None:
state = {"pipelines": None}
assert has_pipelines(state) == "no"
def test_returns_yes_with_multiple_pipelines(self) -> None:
state = {"pipelines": [{"id": 1}, {"id": 2}]}
assert has_pipelines(state) == "yes"
# ---------------------------------------------------------------------------
# has_pending_approvals
# ---------------------------------------------------------------------------
class TestHasPendingApprovals:
def test_returns_yes_when_non_empty_list(self) -> None:
state = {"pending_approvals": [{"approval_id": "abc"}]}
assert has_pending_approvals(state) == "yes"
def test_returns_no_when_empty_list(self) -> None:
state = {"pending_approvals": []}
assert has_pending_approvals(state) == "no"
def test_returns_no_when_field_missing(self) -> None:
state = {}
assert has_pending_approvals(state) == "no"
def test_returns_no_when_none(self) -> None:
state = {"pending_approvals": None}
assert has_pending_approvals(state) == "no"
def test_returns_yes_with_multiple_approvals(self) -> None:
state = {"pending_approvals": [{"approval_id": "a"}, {"approval_id": "b"}]}
assert has_pending_approvals(state) == "yes"
# ---------------------------------------------------------------------------
# route_ci_result
# ---------------------------------------------------------------------------
class TestRouteCiResult:
"""Tests for route_ci_result routing function."""
def test_returns_ci_passed_when_succeeded(self) -> None:
state = {"ci_build_result": "succeeded"}
assert route_ci_result(state) == "ci_passed"
def test_returns_ci_failed_when_failed(self) -> None:
state = {"ci_build_result": "failed"}
assert route_ci_result(state) == "ci_failed"
def test_returns_ci_failed_when_canceled(self) -> None:
state = {"ci_build_result": "canceled"}
assert route_ci_result(state) == "ci_failed"
def test_returns_ci_failed_when_partially_succeeded(self) -> None:
state = {"ci_build_result": "partiallySucceeded"}
assert route_ci_result(state) == "ci_failed"
def test_returns_ci_failed_when_field_missing(self) -> None:
state = {}
assert route_ci_result(state) == "ci_failed"
def test_returns_ci_failed_when_none(self) -> None:
state = {"ci_build_result": None}
assert route_ci_result(state) == "ci_failed"
def test_returns_ci_failed_when_empty_string(self) -> None:
state = {"ci_build_result": ""}
assert route_ci_result(state) == "ci_failed"
def test_case_sensitive_succeeded(self) -> None:
# AzDo returns "succeeded" (lowercase)
state = {"ci_build_result": "succeeded"}
assert route_ci_result(state) == "ci_passed"
# ---------------------------------------------------------------------------
# route_approval_stage
# ---------------------------------------------------------------------------
class TestRouteApprovalStage:
"""Tests for route_approval_stage routing function."""
def test_returns_all_deployed_when_no_pending_approvals(self) -> None:
state = {"pending_approvals": []}
assert route_approval_stage(state) == "all_deployed"
def test_returns_all_deployed_when_field_missing(self) -> None:
state = {}
assert route_approval_stage(state) == "all_deployed"
def test_returns_all_deployed_when_none(self) -> None:
state = {"pending_approvals": None}
assert route_approval_stage(state) == "all_deployed"
def test_returns_sandbox_pending_when_sandbox_approval_exists(self) -> None:
state = {
"current_stage": "sandbox_pending",
"pending_approvals": [{"approval_id": "x", "stage_name": "Sandbox"}],
}
assert route_approval_stage(state) == "sandbox_pending"
def test_returns_prod_pending_when_prod_approval_exists(self) -> None:
state = {
"current_stage": "prod_pending",
"pending_approvals": [{"approval_id": "y", "stage_name": "Production"}],
}
assert route_approval_stage(state) == "prod_pending"
def test_uses_current_stage_field_when_present(self) -> None:
state = {
"current_stage": "sandbox_pending",
"pending_approvals": [{"approval_id": "z"}],
}
assert route_approval_stage(state) == "sandbox_pending"
def test_returns_all_deployed_when_no_current_stage_and_has_approvals(self) -> None:
# When current_stage is missing but approvals exist, stage is unknown
# so we treat as sandbox by default (first stage)
state = {
"pending_approvals": [{"approval_id": "a"}],
}
# Must return either sandbox_pending or prod_pending (not all_deployed)
result = route_approval_stage(state)
assert result in ("sandbox_pending", "prod_pending")
def test_sandbox_pending_from_current_stage(self) -> None:
state = {"current_stage": "sandbox_pending", "pending_approvals": [{"approval_id": "x"}]}
assert route_approval_stage(state) == "sandbox_pending"
def test_prod_pending_from_current_stage(self) -> None:
state = {"current_stage": "prod_pending", "pending_approvals": [{"approval_id": "x"}]}
assert route_approval_stage(state) == "prod_pending"
# ---------------------------------------------------------------------------
# route_after_fetch
# ---------------------------------------------------------------------------
class TestRouteAfterFetch:
"""Tests for route_after_fetch — 3-way routing replacing is_pr_already_merged."""
def test_returns_merged_when_pr_already_merged(self) -> None:
state = {"pr_already_merged": True}
assert route_after_fetch(state) == "merged"
def test_returns_active_with_ticket_when_active_and_has_ticket(self) -> None:
state = {"pr_already_merged": False, "has_ticket": True}
assert route_after_fetch(state) == "active_with_ticket"
def test_returns_active_no_ticket_when_active_and_no_ticket(self) -> None:
state = {"pr_already_merged": False, "has_ticket": False}
assert route_after_fetch(state) == "active_no_ticket"
def test_returns_active_no_ticket_when_has_ticket_missing(self) -> None:
state = {"pr_already_merged": False}
assert route_after_fetch(state) == "active_no_ticket"
def test_returns_active_no_ticket_when_has_ticket_none(self) -> None:
state = {"pr_already_merged": False, "has_ticket": None}
assert route_after_fetch(state) == "active_no_ticket"
def test_returns_active_no_ticket_when_all_fields_missing(self) -> None:
state = {}
assert route_after_fetch(state) == "active_no_ticket"
def test_merged_takes_precedence_over_has_ticket(self) -> None:
# Even if has_ticket is True, merged PR should route to "merged"
state = {"pr_already_merged": True, "has_ticket": True}
assert route_after_fetch(state) == "merged"
def test_returns_active_with_ticket_ignores_merged_false(self) -> None:
state = {"pr_already_merged": False, "has_ticket": True}
result = route_after_fetch(state)
assert result != "merged"
assert result == "active_with_ticket"