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:
302
tests/graph/test_routing.py
Normal file
302
tests/graph/test_routing.py
Normal file
@@ -0,0 +1,302 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user