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

242 lines
9.1 KiB
Python

"""Tests for LangGraph state module. Written FIRST (TDD RED phase)."""
import json
from release_agent.state import ReleaseState, add_errors, add_messages
# ---------------------------------------------------------------------------
# ReleaseState tests
# ---------------------------------------------------------------------------
class TestReleaseState:
"""Tests for ReleaseState TypedDict."""
def test_empty_state_is_valid(self) -> None:
# total=False means all fields are optional
state: ReleaseState = {}
assert state == {}
def test_partial_state_with_repo(self) -> None:
state: ReleaseState = {"repo_name": "my-repo"}
assert state["repo_name"] == "my-repo"
def test_partial_state_with_messages(self) -> None:
state: ReleaseState = {"messages": ["Hello"]}
assert state["messages"] == ["Hello"]
def test_partial_state_with_errors(self) -> None:
state: ReleaseState = {"errors": ["Something went wrong"]}
assert state["errors"] == ["Something went wrong"]
def test_state_with_pr_id(self) -> None:
state: ReleaseState = {"pr_id": "PR-42"}
assert state["pr_id"] == "PR-42"
def test_state_with_ticket_id(self) -> None:
state: ReleaseState = {"ticket_id": "ALLPOST-100"}
assert state["ticket_id"] == "ALLPOST-100"
def test_state_with_version(self) -> None:
state: ReleaseState = {"version": "v1.0.1"}
assert state["version"] == "v1.0.1"
# Phase 3 new fields
def test_state_with_webhook_payload(self) -> None:
state: ReleaseState = {"webhook_payload": {"event_type": "git.pullrequest.merged"}}
assert state["webhook_payload"]["event_type"] == "git.pullrequest.merged"
def test_state_with_pr_info(self) -> None:
state: ReleaseState = {"pr_info": {"pr_id": "42", "repo_name": "my-repo"}}
assert state["pr_info"]["repo_name"] == "my-repo"
def test_state_with_pr_diff(self) -> None:
state: ReleaseState = {"pr_diff": "edit: src/main.py"}
assert state["pr_diff"] == "edit: src/main.py"
def test_state_with_last_merge_source_commit(self) -> None:
state: ReleaseState = {"last_merge_source_commit": "abc123"}
assert state["last_merge_source_commit"] == "abc123"
def test_state_with_ticket_summary(self) -> None:
state: ReleaseState = {"ticket_summary": "Fix login bug"}
assert state["ticket_summary"] == "Fix login bug"
def test_state_with_has_ticket(self) -> None:
state: ReleaseState = {"has_ticket": True}
assert state["has_ticket"] is True
def test_state_with_review_result(self) -> None:
state: ReleaseState = {"review_result": {"verdict": "approve", "summary": "LGTM"}}
assert state["review_result"]["verdict"] == "approve"
def test_state_with_review_approved(self) -> None:
state: ReleaseState = {"review_approved": True}
assert state["review_approved"] is True
def test_state_with_staging(self) -> None:
state: ReleaseState = {"staging": {"version": "v1.0.0", "tickets": []}}
assert state["staging"]["version"] == "v1.0.0"
def test_state_with_pr_already_merged(self) -> None:
state: ReleaseState = {"pr_already_merged": False}
assert state["pr_already_merged"] is False
def test_state_with_release_pr_id(self) -> None:
state: ReleaseState = {"release_pr_id": "123"}
assert state["release_pr_id"] == "123"
def test_state_with_release_pr_commit(self) -> None:
state: ReleaseState = {"release_pr_commit": "deadbeef"}
assert state["release_pr_commit"] == "deadbeef"
def test_state_with_pipelines(self) -> None:
state: ReleaseState = {"pipelines": [{"id": 1, "name": "build"}]}
assert len(state["pipelines"]) == 1
def test_state_with_triggered_builds(self) -> None:
state: ReleaseState = {"triggered_builds": [{"id": 99}]}
assert state["triggered_builds"][0]["id"] == 99
def test_state_with_pending_approvals(self) -> None:
state: ReleaseState = {"pending_approvals": [{"approval_id": "aaa"}]}
assert state["pending_approvals"][0]["approval_id"] == "aaa"
def test_state_with_continue_to_release(self) -> None:
state: ReleaseState = {"continue_to_release": True}
assert state["continue_to_release"] is True
# Phase 5: CI/CD and approval fields
def test_state_with_ci_build_id(self) -> None:
state: ReleaseState = {"ci_build_id": 12345}
assert state["ci_build_id"] == 12345
def test_state_with_ci_build_status(self) -> None:
state: ReleaseState = {"ci_build_status": "inProgress"}
assert state["ci_build_status"] == "inProgress"
def test_state_with_ci_build_result(self) -> None:
state: ReleaseState = {"ci_build_result": "succeeded"}
assert state["ci_build_result"] == "succeeded"
def test_state_with_ci_build_url(self) -> None:
state: ReleaseState = {"ci_build_url": "https://dev.azure.com/org/proj/_build/results?buildId=99"}
assert "buildId=99" in state["ci_build_url"]
def test_state_with_release_definition_id(self) -> None:
state: ReleaseState = {"release_definition_id": 7}
assert state["release_definition_id"] == 7
def test_state_with_release_id(self) -> None:
state: ReleaseState = {"release_id": 456}
assert state["release_id"] == 456
def test_state_with_current_stage(self) -> None:
state: ReleaseState = {"current_stage": "sandbox_pending"}
assert state["current_stage"] == "sandbox_pending"
def test_state_with_approval_message_ts(self) -> None:
state: ReleaseState = {"approval_message_ts": "1234567890.123456"}
assert state["approval_message_ts"] == "1234567890.123456"
def test_state_with_slack_message_ts(self) -> None:
state: ReleaseState = {"slack_message_ts": "9876543210.000001"}
assert state["slack_message_ts"] == "9876543210.000001"
def test_state_json_serializable_empty(self) -> None:
state: ReleaseState = {}
serialized = json.dumps(state)
assert json.loads(serialized) == {}
def test_state_json_serializable_with_strings(self) -> None:
state: ReleaseState = {
"repo_name": "my-repo",
"pr_id": "PR-1",
"ticket_id": "ALLPOST-1",
"version": "v1.0.0",
}
serialized = json.dumps(state)
loaded = json.loads(serialized)
assert loaded["repo_name"] == "my-repo"
assert loaded["pr_id"] == "PR-1"
def test_state_json_serializable_with_lists(self) -> None:
state: ReleaseState = {
"messages": ["msg1", "msg2"],
"errors": ["err1"],
}
serialized = json.dumps(state)
loaded = json.loads(serialized)
assert loaded["messages"] == ["msg1", "msg2"]
assert loaded["errors"] == ["err1"]
# ---------------------------------------------------------------------------
# Reducer tests
# ---------------------------------------------------------------------------
class TestAddMessages:
"""Tests for add_messages reducer."""
def test_accumulates_to_empty(self) -> None:
result = add_messages([], ["Hello"])
assert result == ["Hello"]
def test_accumulates_to_existing(self) -> None:
result = add_messages(["Hello"], ["World"])
assert result == ["Hello", "World"]
def test_accumulates_multiple(self) -> None:
result = add_messages(["A", "B"], ["C", "D"])
assert result == ["A", "B", "C", "D"]
def test_existing_unchanged(self) -> None:
existing = ["Hello"]
add_messages(existing, ["World"])
# Original should not be mutated
assert existing == ["Hello"]
def test_empty_new_messages(self) -> None:
result = add_messages(["Hello"], [])
assert result == ["Hello"]
def test_both_empty(self) -> None:
result = add_messages([], [])
assert result == []
def test_returns_new_list(self) -> None:
existing = ["Hello"]
new_msgs = ["World"]
result = add_messages(existing, new_msgs)
assert result is not existing
assert result is not new_msgs
class TestAddErrors:
"""Tests for add_errors reducer."""
def test_accumulates_to_empty(self) -> None:
result = add_errors([], ["Error occurred"])
assert result == ["Error occurred"]
def test_accumulates_to_existing(self) -> None:
result = add_errors(["First error"], ["Second error"])
assert result == ["First error", "Second error"]
def test_existing_unchanged(self) -> None:
existing = ["First error"]
add_errors(existing, ["Second error"])
assert existing == ["First error"]
def test_empty_new_errors(self) -> None:
result = add_errors(["Existing"], [])
assert result == ["Existing"]
def test_both_empty(self) -> None:
result = add_errors([], [])
assert result == []
def test_returns_new_list(self) -> None:
existing = ["Error"]
result = add_errors(existing, ["New error"])
assert result is not existing