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:
283
tests/graph/test_dependencies.py
Normal file
283
tests/graph/test_dependencies.py
Normal file
@@ -0,0 +1,283 @@
|
||||
"""Tests for graph/dependencies.py. Written FIRST (TDD RED phase).
|
||||
|
||||
Covers:
|
||||
- ToolClients frozen dataclass
|
||||
- StagingStore Protocol (structural check)
|
||||
- JsonFileStagingStore file I/O operations
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from release_agent.graph.dependencies import JsonFileStagingStore, StagingStore, ToolClients
|
||||
from release_agent.models.release import ArchivedRelease, StagingRelease
|
||||
from release_agent.models.ticket import TicketEntry
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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") -> StagingRelease:
|
||||
return StagingRelease(
|
||||
version=version,
|
||||
repo=repo,
|
||||
started_at=date(2025, 1, 1),
|
||||
tickets=[],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ToolClients tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestToolClients:
|
||||
"""Tests for the ToolClients frozen dataclass."""
|
||||
|
||||
def test_can_be_constructed_with_all_fields(self) -> None:
|
||||
azdo = AsyncMock()
|
||||
jira = AsyncMock()
|
||||
slack = AsyncMock()
|
||||
reviewer = AsyncMock()
|
||||
clients = ToolClients(azdo=azdo, jira=jira, slack=slack, reviewer=reviewer)
|
||||
assert clients.azdo is azdo
|
||||
assert clients.jira is jira
|
||||
assert clients.slack is slack
|
||||
assert clients.reviewer is reviewer
|
||||
|
||||
def test_is_frozen_cannot_reassign_field(self) -> None:
|
||||
clients = ToolClients(
|
||||
azdo=AsyncMock(), jira=AsyncMock(), slack=AsyncMock(), reviewer=AsyncMock()
|
||||
)
|
||||
with pytest.raises((AttributeError, TypeError)):
|
||||
clients.azdo = AsyncMock() # type: ignore[misc]
|
||||
|
||||
def test_fields_are_accessible_by_name(self) -> None:
|
||||
azdo = object()
|
||||
clients = ToolClients(
|
||||
azdo=azdo, jira=object(), slack=object(), reviewer=object()
|
||||
)
|
||||
assert clients.azdo is azdo
|
||||
|
||||
def test_equality_for_same_instances(self) -> None:
|
||||
azdo = AsyncMock()
|
||||
jira = AsyncMock()
|
||||
slack = AsyncMock()
|
||||
reviewer = AsyncMock()
|
||||
c1 = ToolClients(azdo=azdo, jira=jira, slack=slack, reviewer=reviewer)
|
||||
c2 = ToolClients(azdo=azdo, jira=jira, slack=slack, reviewer=reviewer)
|
||||
assert c1 == c2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# StagingStore Protocol structural tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestStagingStoreProtocol:
|
||||
"""Verify that the Protocol is structurally correct."""
|
||||
|
||||
def test_json_file_store_satisfies_protocol(self, tmp_path: Path) -> None:
|
||||
store = JsonFileStagingStore(directory=tmp_path)
|
||||
# runtime_checkable would need @runtime_checkable; check duck-typing instead
|
||||
assert hasattr(store, "load")
|
||||
assert hasattr(store, "save")
|
||||
assert hasattr(store, "archive")
|
||||
assert hasattr(store, "list_versions")
|
||||
|
||||
def test_protocol_is_importable(self) -> None:
|
||||
# Just import-level check
|
||||
assert StagingStore is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JsonFileStagingStore tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestJsonFileStagingStore:
|
||||
"""Tests for JsonFileStagingStore using tmp_path for file I/O."""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# load
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def test_load_returns_none_when_file_missing(self, tmp_path: Path) -> None:
|
||||
store = JsonFileStagingStore(directory=tmp_path)
|
||||
result = await store.load("nonexistent-repo")
|
||||
assert result is None
|
||||
|
||||
async def test_load_returns_staging_release_after_save(self, tmp_path: Path) -> None:
|
||||
store = JsonFileStagingStore(directory=tmp_path)
|
||||
staging = _make_staging()
|
||||
await store.save(staging)
|
||||
loaded = await store.load("my-repo")
|
||||
assert loaded is not None
|
||||
assert loaded.version == "v1.0.0"
|
||||
assert loaded.repo == "my-repo"
|
||||
|
||||
async def test_load_returns_staging_with_tickets(self, tmp_path: Path) -> None:
|
||||
store = JsonFileStagingStore(directory=tmp_path)
|
||||
staging = _make_staging().add_ticket(_make_ticket("BILL-10"))
|
||||
await store.save(staging)
|
||||
loaded = await store.load("my-repo")
|
||||
assert loaded is not None
|
||||
assert len(loaded.tickets) == 1
|
||||
assert loaded.tickets[0].id == "BILL-10"
|
||||
|
||||
async def test_load_is_read_only_does_not_mutate_stored(self, tmp_path: Path) -> None:
|
||||
store = JsonFileStagingStore(directory=tmp_path)
|
||||
staging = _make_staging()
|
||||
await store.save(staging)
|
||||
loaded1 = await store.load("my-repo")
|
||||
loaded2 = await store.load("my-repo")
|
||||
assert loaded1 is not loaded2 # fresh objects each time
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# save
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def test_save_creates_file_in_directory(self, tmp_path: Path) -> None:
|
||||
store = JsonFileStagingStore(directory=tmp_path)
|
||||
staging = _make_staging(repo="api-service")
|
||||
await store.save(staging)
|
||||
expected_path = tmp_path / "api-service.json"
|
||||
assert expected_path.exists()
|
||||
|
||||
async def test_save_overwrites_existing_file(self, tmp_path: Path) -> None:
|
||||
store = JsonFileStagingStore(directory=tmp_path)
|
||||
staging_v1 = _make_staging(version="v1.0.0")
|
||||
staging_v2 = _make_staging(version="v1.0.1")
|
||||
await store.save(staging_v1)
|
||||
await store.save(staging_v2)
|
||||
loaded = await store.load("my-repo")
|
||||
assert loaded is not None
|
||||
assert loaded.version == "v1.0.1"
|
||||
|
||||
async def test_save_writes_valid_json(self, tmp_path: Path) -> None:
|
||||
store = JsonFileStagingStore(directory=tmp_path)
|
||||
staging = _make_staging()
|
||||
await store.save(staging)
|
||||
raw = (tmp_path / "my-repo.json").read_text()
|
||||
data = json.loads(raw)
|
||||
assert data["version"] == "v1.0.0"
|
||||
assert data["repo"] == "my-repo"
|
||||
|
||||
async def test_save_does_not_mutate_staging_release(self, tmp_path: Path) -> None:
|
||||
store = JsonFileStagingStore(directory=tmp_path)
|
||||
staging = _make_staging()
|
||||
original_tickets = list(staging.tickets)
|
||||
await store.save(staging)
|
||||
assert list(staging.tickets) == original_tickets
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# archive
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def test_archive_removes_staging_file(self, tmp_path: Path) -> None:
|
||||
store = JsonFileStagingStore(directory=tmp_path)
|
||||
staging = _make_staging()
|
||||
await store.save(staging)
|
||||
await store.archive(staging, date(2025, 6, 1))
|
||||
assert await store.load("my-repo") is None
|
||||
|
||||
async def test_archive_creates_archive_file(self, tmp_path: Path) -> None:
|
||||
store = JsonFileStagingStore(directory=tmp_path)
|
||||
staging = _make_staging(repo="my-repo", version="v1.0.0")
|
||||
await store.save(staging)
|
||||
await store.archive(staging, date(2025, 6, 1))
|
||||
archive_path = tmp_path / "my-repo_v1.0.0_2025-06-01.json"
|
||||
assert archive_path.exists()
|
||||
|
||||
async def test_archive_file_contains_released_at(self, tmp_path: Path) -> None:
|
||||
store = JsonFileStagingStore(directory=tmp_path)
|
||||
staging = _make_staging()
|
||||
await store.save(staging)
|
||||
release_date = date(2025, 6, 1)
|
||||
await store.archive(staging, release_date)
|
||||
archive_path = tmp_path / "my-repo_v1.0.0_2025-06-01.json"
|
||||
data = json.loads(archive_path.read_text())
|
||||
assert data["released_at"] == "2025-06-01"
|
||||
|
||||
async def test_archive_without_prior_save_creates_archive(self, tmp_path: Path) -> None:
|
||||
store = JsonFileStagingStore(directory=tmp_path)
|
||||
staging = _make_staging()
|
||||
await store.archive(staging, date(2025, 6, 1))
|
||||
archive_path = tmp_path / "my-repo_v1.0.0_2025-06-01.json"
|
||||
assert archive_path.exists()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# list_versions
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def test_list_versions_empty_directory(self, tmp_path: Path) -> None:
|
||||
store = JsonFileStagingStore(directory=tmp_path)
|
||||
versions = await store.list_versions("my-repo")
|
||||
assert versions == []
|
||||
|
||||
async def test_list_versions_returns_version_from_staging_file(self, tmp_path: Path) -> None:
|
||||
store = JsonFileStagingStore(directory=tmp_path)
|
||||
await store.save(_make_staging(version="v2.1.0"))
|
||||
versions = await store.list_versions("my-repo")
|
||||
assert "v2.1.0" in versions
|
||||
|
||||
async def test_list_versions_includes_archived_versions(self, tmp_path: Path) -> None:
|
||||
store = JsonFileStagingStore(directory=tmp_path)
|
||||
staging = _make_staging(version="v1.5.0")
|
||||
await store.save(staging)
|
||||
await store.archive(staging, date(2025, 3, 1))
|
||||
# Now save a new staging for the same repo
|
||||
await store.save(_make_staging(version="v1.6.0"))
|
||||
versions = await store.list_versions("my-repo")
|
||||
assert "v1.5.0" in versions
|
||||
assert "v1.6.0" in versions
|
||||
|
||||
async def test_list_versions_only_returns_versions_for_given_repo(self, tmp_path: Path) -> None:
|
||||
store = JsonFileStagingStore(directory=tmp_path)
|
||||
await store.save(_make_staging(repo="repo-a", version="v1.0.0"))
|
||||
await store.save(_make_staging(repo="repo-b", version="v2.0.0"))
|
||||
versions_a = await store.list_versions("repo-a")
|
||||
assert "v1.0.0" in versions_a
|
||||
# repo-b version should not appear in repo-a's list
|
||||
assert "v2.0.0" not in versions_a
|
||||
|
||||
async def test_list_versions_no_duplicates(self, tmp_path: Path) -> None:
|
||||
store = JsonFileStagingStore(directory=tmp_path)
|
||||
await store.save(_make_staging(version="v1.0.0"))
|
||||
versions = await store.list_versions("my-repo")
|
||||
assert len(versions) == len(set(versions))
|
||||
|
||||
async def test_list_versions_multiple_archives(self, tmp_path: Path) -> None:
|
||||
store = JsonFileStagingStore(directory=tmp_path)
|
||||
for i in range(3):
|
||||
staging = _make_staging(version=f"v1.0.{i}")
|
||||
await store.archive(staging, date(2025, 1, i + 1))
|
||||
versions = await store.list_versions("my-repo")
|
||||
assert len(versions) == 3
|
||||
assert "v1.0.0" in versions
|
||||
assert "v1.0.1" in versions
|
||||
assert "v1.0.2" in versions
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# directory creation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_store_creates_directory_if_not_exists(self, tmp_path: Path) -> None:
|
||||
new_dir = tmp_path / "staging_data"
|
||||
assert not new_dir.exists()
|
||||
JsonFileStagingStore(directory=new_dir)
|
||||
assert new_dir.exists()
|
||||
Reference in New Issue
Block a user