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:
Yaojia Wang
2026-03-24 17:38:23 +01:00
commit f5c2733cfb
104 changed files with 19721 additions and 0 deletions

0
tests/tools/__init__.py Normal file
View File

View File

@@ -0,0 +1,9 @@
{
"id": "approval-uuid-123",
"status": "approved",
"approver": {
"id": "user-uuid-456",
"displayName": "Release Bot"
},
"comments": "Approved via release agent"
}

View File

@@ -0,0 +1,9 @@
{
"id": 1001,
"buildNumber": "20240115.1",
"status": "completed",
"result": "succeeded",
"queueTime": "2024-01-15T10:00:00Z",
"startTime": "2024-01-15T10:01:00Z",
"finishTime": "2024-01-15T10:10:00Z"
}

View File

@@ -0,0 +1,8 @@
{
"pullRequestId": 99,
"title": "Release v1.2.0",
"status": "active",
"sourceRefName": "refs/heads/release/v1.2.0",
"targetRefName": "refs/heads/main",
"url": "https://dev.azure.com/my-org/my-project/_apis/git/repositories/my-repo/pullRequests/99"
}

View File

@@ -0,0 +1,8 @@
{
"pullRequestId": 42,
"status": "completed",
"title": "Fix the auth bug",
"completionOptions": {
"mergeStrategy": "squash"
}
}

View File

@@ -0,0 +1,15 @@
{
"value": [
{
"id": 10,
"name": "Release Pipeline",
"folder": "\\"
},
{
"id": 20,
"name": "Build Pipeline",
"folder": "\\"
}
],
"count": 2
}

View File

@@ -0,0 +1,16 @@
{
"pullRequestId": 42,
"title": "Fix the auth bug",
"status": "active",
"sourceRefName": "refs/heads/bug/ALLPOST-999_fix-auth",
"targetRefName": "refs/heads/main",
"url": "https://dev.azure.com/my-org/my-project/_apis/git/repositories/my-repo/pullRequests/42",
"repository": {
"id": "repo-uuid-123",
"name": "my-repo",
"remoteUrl": "https://dev.azure.com/my-org/my-project/_git/my-repo"
},
"lastMergeSourceCommit": {
"commitId": "abc123def456"
}
}

View File

@@ -0,0 +1,11 @@
diff --git a/src/auth.py b/src/auth.py
index 1234567..abcdefg 100644
--- a/src/auth.py
+++ b/src/auth.py
@@ -10,6 +10,10 @@ class AuthService:
def authenticate(self, token: str) -> bool:
- return token == "hardcoded"
+ return self._validate_token(token)
+
+ def _validate_token(self, token: str) -> bool:
+ return len(token) > 0 and token.startswith("Bearer ")

View File

@@ -0,0 +1,11 @@
{
"id": 1001,
"buildNumber": "20240115.1",
"status": "notStarted",
"queueTime": "2024-01-15T10:00:00Z",
"definition": {
"id": 10,
"name": "Release Pipeline"
},
"sourceBranch": "refs/heads/main"
}

View File

@@ -0,0 +1,10 @@
{
"id": "12345",
"key": "ALLPOST-100",
"fields": {
"summary": "Fix the authentication bug",
"status": {
"name": "In Progress"
}
}
}

View File

@@ -0,0 +1,20 @@
{
"transitions": [
{
"id": "11",
"name": "To Do"
},
{
"id": "21",
"name": "In Progress"
},
{
"id": "31",
"name": "Done"
},
{
"id": "41",
"name": "Released"
}
]
}

819
tests/tools/test_azdo.py Normal file
View File

@@ -0,0 +1,819 @@
"""Tests for AzDoClient. Written FIRST (TDD RED phase)."""
import json
from pathlib import Path
import httpx
import pytest
from release_agent.exceptions import AuthenticationError, NotFoundError, ServiceError
from release_agent.models.build import ApprovalRecord, BuildStatus
from release_agent.models.pipeline import PipelineInfo
from release_agent.models.pr import PRInfo
from release_agent.tools.azdo import AzDoClient
# ---------------------------------------------------------------------------
# Fixture helpers
# ---------------------------------------------------------------------------
FIXTURES = Path(__file__).parent / "fixtures"
def _load_json(name: str) -> dict:
return json.loads((FIXTURES / name).read_text())
def _load_text(name: str) -> str:
return (FIXTURES / name).read_text()
def _make_transport(routes: dict[tuple[str, str], tuple[int, bytes | str]]) -> httpx.MockTransport:
"""Build a MockTransport that dispatches based on (method, url_substring)."""
def handler(request: httpx.Request) -> httpx.Response:
url = str(request.url)
method = request.method
for (m, url_fragment), (status, body) in routes.items():
if m == method and url_fragment in url:
content = body if isinstance(body, bytes) else body.encode()
return httpx.Response(status_code=status, content=content)
return httpx.Response(status_code=404, content=b'{"message": "Not found"}')
return httpx.MockTransport(handler)
def _make_client(routes: dict) -> AzDoClient:
"""Create an AzDoClient with mocked HTTP transport."""
transport = _make_transport(routes)
http_client = httpx.AsyncClient(transport=transport)
vsrm_client = httpx.AsyncClient(transport=transport)
return AzDoClient(
base_url="https://dev.azure.com/my-org/my-project/_apis",
vsrm_base_url="https://vsrm.dev.azure.com/my-org/my-project/_apis",
pat="test-pat",
http_client=http_client,
vsrm_http_client=vsrm_client,
)
# ---------------------------------------------------------------------------
# AzDoClient construction tests
# ---------------------------------------------------------------------------
class TestAzDoClientConstruction:
"""Tests for AzDoClient initialization."""
def test_can_be_instantiated_with_injected_clients(self) -> None:
transport = httpx.MockTransport(lambda r: httpx.Response(200, content=b"{}"))
http_client = httpx.AsyncClient(transport=transport)
vsrm_client = httpx.AsyncClient(transport=transport)
client = AzDoClient(
base_url="https://dev.azure.com/org/proj/_apis",
vsrm_base_url="https://vsrm.dev.azure.com/org/proj/_apis",
pat="my-pat",
http_client=http_client,
vsrm_http_client=vsrm_client,
)
assert client is not None
async def test_context_manager_closes_clients(self) -> None:
transport = httpx.MockTransport(lambda r: httpx.Response(200, content=b"{}"))
http_client = httpx.AsyncClient(transport=transport)
vsrm_client = httpx.AsyncClient(transport=transport)
async with AzDoClient(
base_url="https://dev.azure.com/org/proj/_apis",
vsrm_base_url="https://vsrm.dev.azure.com/org/proj/_apis",
pat="my-pat",
http_client=http_client,
vsrm_http_client=vsrm_client,
) as client:
assert client is not None
# After context manager exits, clients should be closed
assert http_client.is_closed
assert vsrm_client.is_closed
# ---------------------------------------------------------------------------
# get_pr tests
# ---------------------------------------------------------------------------
class TestGetPr:
"""Tests for AzDoClient.get_pr."""
async def test_returns_pr_info(self) -> None:
pr_data = _load_json("azdo_pr.json")
routes = {("GET", "pullRequests/42"): (200, json.dumps(pr_data))}
client = _make_client(routes)
result = await client.get_pr(42)
assert isinstance(result, PRInfo)
assert result.pr_id == "42"
async def test_pr_title_extracted(self) -> None:
pr_data = _load_json("azdo_pr.json")
routes = {("GET", "pullRequests/42"): (200, json.dumps(pr_data))}
client = _make_client(routes)
result = await client.get_pr(42)
assert result.pr_title == "Fix the auth bug"
async def test_pr_branch_extracted(self) -> None:
pr_data = _load_json("azdo_pr.json")
routes = {("GET", "pullRequests/42"): (200, json.dumps(pr_data))}
client = _make_client(routes)
result = await client.get_pr(42)
assert "ALLPOST-999" in result.branch or "bug" in result.branch
async def test_pr_status_extracted(self) -> None:
pr_data = _load_json("azdo_pr.json")
routes = {("GET", "pullRequests/42"): (200, json.dumps(pr_data))}
client = _make_client(routes)
result = await client.get_pr(42)
assert result.pr_status == "active"
async def test_404_raises_not_found(self) -> None:
routes = {("GET", "pullRequests/999"): (404, b'{"message": "PR not found"}')}
client = _make_client(routes)
with pytest.raises(NotFoundError):
await client.get_pr(999)
async def test_401_raises_authentication_error(self) -> None:
routes = {("GET", "pullRequests/42"): (401, b'{"message": "Unauthorized"}')}
client = _make_client(routes)
with pytest.raises(AuthenticationError):
await client.get_pr(42)
async def test_500_raises_service_error(self) -> None:
routes = {("GET", "pullRequests/42"): (500, b'{"message": "Internal error"}')}
client = _make_client(routes)
with pytest.raises(ServiceError):
await client.get_pr(42)
# ---------------------------------------------------------------------------
# get_pr_diff tests
# ---------------------------------------------------------------------------
class TestGetPrDiff:
"""Tests for AzDoClient.get_pr_diff."""
async def test_returns_diff_string(self) -> None:
pr_data = _load_json("azdo_pr.json")
routes = {
("GET", "pullRequests/42"): (200, json.dumps(pr_data)),
("GET", "diffs"): (200, json.dumps({
"changes": [
{
"item": {"path": "/src/auth.py"},
"changeType": "edit",
}
]
})),
}
client = _make_client(routes)
result = await client.get_pr_diff(42)
assert isinstance(result, str)
async def test_diff_includes_file_paths(self) -> None:
pr_data = _load_json("azdo_pr.json")
diff_data = {
"changes": [
{"item": {"path": "/src/auth.py"}, "changeType": "edit"},
{"item": {"path": "/src/util.py"}, "changeType": "add"},
]
}
def handler(request: httpx.Request) -> httpx.Response:
url = str(request.url)
if "diffs" in url:
return httpx.Response(200, content=json.dumps(diff_data).encode())
if "pullRequests/42" in url:
return httpx.Response(200, content=json.dumps(pr_data).encode())
return httpx.Response(404, content=b"{}")
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
vsrm_client = httpx.AsyncClient(transport=transport)
client = AzDoClient(
base_url="https://dev.azure.com/my-org/my-project/_apis",
vsrm_base_url="https://vsrm.dev.azure.com/my-org/my-project/_apis",
pat="test-pat",
http_client=http_client,
vsrm_http_client=vsrm_client,
)
result = await client.get_pr_diff(42)
assert "/src/auth.py" in result
assert "/src/util.py" in result
async def test_empty_changes_returns_empty_string(self) -> None:
pr_data = _load_json("azdo_pr.json")
diff_data: dict = {"changes": []}
def handler(request: httpx.Request) -> httpx.Response:
url = str(request.url)
if "diffs" in url:
return httpx.Response(200, content=json.dumps(diff_data).encode())
if "pullRequests/42" in url:
return httpx.Response(200, content=json.dumps(pr_data).encode())
return httpx.Response(404, content=b"{}")
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
vsrm_client = httpx.AsyncClient(transport=transport)
client = AzDoClient(
base_url="https://dev.azure.com/my-org/my-project/_apis",
vsrm_base_url="https://vsrm.dev.azure.com/my-org/my-project/_apis",
pat="test-pat",
http_client=http_client,
vsrm_http_client=vsrm_client,
)
result = await client.get_pr_diff(42)
assert result == ""
async def test_404_raises_not_found(self) -> None:
routes = {("GET", "pullRequests/999"): (404, b'{"message": "PR not found"}')}
client = _make_client(routes)
with pytest.raises(NotFoundError):
await client.get_pr_diff(999)
async def test_pr_without_url_field_uses_remote_url(self) -> None:
"""When the API response omits the 'url' field, fallback URL is built."""
pr_data = {
"pullRequestId": 42,
"title": "Fix bug",
"status": "active",
"sourceRefName": "refs/heads/fix/ALLPOST-1_fix",
"repository": {
"id": "repo-uuid",
"name": "my-repo",
"remoteUrl": "https://dev.azure.com/org/proj/_git/my-repo",
},
# NOTE: 'url' field is intentionally omitted
}
routes = {
("GET", "pullRequests/42"): (200, json.dumps(pr_data)),
("GET", "diffs"): (200, json.dumps({"changes": []})),
}
client = _make_client(routes)
result = await client.get_pr(42)
assert "42" in str(result.pr_url)
# ---------------------------------------------------------------------------
# merge_pr tests
# ---------------------------------------------------------------------------
class TestMergePr:
"""Tests for AzDoClient.merge_pr."""
async def test_returns_true_on_success(self) -> None:
merge_data = _load_json("azdo_merge_pr.json")
routes = {("PATCH", "pullRequests/42"): (200, json.dumps(merge_data))}
client = _make_client(routes)
result = await client.merge_pr(pr_id=42, last_merge_source_commit="abc123def456")
assert result is True
async def test_404_raises_not_found(self) -> None:
routes = {("PATCH", "pullRequests/999"): (404, b'{"message": "Not found"}')}
client = _make_client(routes)
with pytest.raises(NotFoundError):
await client.merge_pr(pr_id=999, last_merge_source_commit="abc123")
async def test_409_raises_service_error(self) -> None:
routes = {("PATCH", "pullRequests/42"): (409, b'{"message": "Conflict"}')}
client = _make_client(routes)
with pytest.raises(ServiceError):
await client.merge_pr(pr_id=42, last_merge_source_commit="abc123")
# ---------------------------------------------------------------------------
# create_pr tests
# ---------------------------------------------------------------------------
class TestCreatePr:
"""Tests for AzDoClient.create_pr."""
async def test_returns_dict_with_pr_id(self) -> None:
create_data = _load_json("azdo_create_pr.json")
routes = {("POST", "pullRequests"): (201, json.dumps(create_data))}
client = _make_client(routes)
result = await client.create_pr(
repo="my-repo",
source="refs/heads/release/v1.2.0",
target="refs/heads/main",
title="Release v1.2.0",
description="Release notes",
)
assert isinstance(result, dict)
assert result["pullRequestId"] == 99
async def test_400_raises_service_error(self) -> None:
routes = {("POST", "pullRequests"): (400, b'{"message": "Bad request"}')}
client = _make_client(routes)
with pytest.raises(ServiceError):
await client.create_pr(
repo="my-repo",
source="refs/heads/release/v1.2.0",
target="refs/heads/main",
title="Release",
description="",
)
# ---------------------------------------------------------------------------
# list_build_pipelines tests
# ---------------------------------------------------------------------------
class TestListBuildPipelines:
"""Tests for AzDoClient.list_build_pipelines."""
async def test_returns_list_of_pipeline_info(self) -> None:
pipeline_data = _load_json("azdo_pipelines.json")
routes = {("GET", "pipelines"): (200, json.dumps(pipeline_data))}
client = _make_client(routes)
result = await client.list_build_pipelines(repo="my-repo")
assert isinstance(result, list)
assert len(result) == 2
assert all(isinstance(p, PipelineInfo) for p in result)
async def test_pipeline_ids_extracted(self) -> None:
pipeline_data = _load_json("azdo_pipelines.json")
routes = {("GET", "pipelines"): (200, json.dumps(pipeline_data))}
client = _make_client(routes)
result = await client.list_build_pipelines(repo="my-repo")
ids = [p.id for p in result]
assert 10 in ids
assert 20 in ids
async def test_empty_list_on_no_pipelines(self) -> None:
routes = {("GET", "pipelines"): (200, json.dumps({"value": [], "count": 0}))}
client = _make_client(routes)
result = await client.list_build_pipelines(repo="my-repo")
assert result == []
# ---------------------------------------------------------------------------
# trigger_pipeline tests
# ---------------------------------------------------------------------------
class TestTriggerPipeline:
"""Tests for AzDoClient.trigger_pipeline."""
async def test_returns_dict_with_build_id(self) -> None:
trigger_data = _load_json("azdo_trigger_pipeline.json")
routes = {("POST", "pipelines/10/runs"): (200, json.dumps(trigger_data))}
client = _make_client(routes)
result = await client.trigger_pipeline(pipeline_id=10, branch="refs/heads/main")
assert isinstance(result, dict)
assert result["id"] == 1001
async def test_404_raises_not_found(self) -> None:
routes = {("POST", "pipelines/999/runs"): (404, b'{"message": "Pipeline not found"}')}
client = _make_client(routes)
with pytest.raises(NotFoundError):
await client.trigger_pipeline(pipeline_id=999, branch="refs/heads/main")
# ---------------------------------------------------------------------------
# get_build_status tests
# ---------------------------------------------------------------------------
class TestGetBuildStatus:
"""Tests for AzDoClient.get_build_status."""
async def test_returns_build_status_object(self) -> None:
build_data = _load_json("azdo_build_status.json")
routes = {("GET", "build/builds/1001"): (200, json.dumps(build_data))}
client = _make_client(routes)
result = await client.get_build_status(build_id=1001)
assert isinstance(result, BuildStatus)
async def test_status_field_populated(self) -> None:
build_data = _load_json("azdo_build_status.json")
routes = {("GET", "build/builds/1001"): (200, json.dumps(build_data))}
client = _make_client(routes)
result = await client.get_build_status(build_id=1001)
assert result.status == "completed"
async def test_result_field_populated(self) -> None:
build_data = _load_json("azdo_build_status.json")
routes = {("GET", "build/builds/1001"): (200, json.dumps(build_data))}
client = _make_client(routes)
result = await client.get_build_status(build_id=1001)
assert result.result == "succeeded"
async def test_build_url_present(self) -> None:
build_data = _load_json("azdo_build_status.json")
routes = {("GET", "build/builds/1001"): (200, json.dumps(build_data))}
client = _make_client(routes)
result = await client.get_build_status(build_id=1001)
# build_url may be None if not in fixture, but field must exist
assert hasattr(result, "build_url")
async def test_result_none_when_not_completed(self) -> None:
build_data = {"id": 99, "status": "inProgress", "buildNumber": "20240101.1"}
routes = {("GET", "build/builds/99"): (200, json.dumps(build_data))}
client = _make_client(routes)
result = await client.get_build_status(build_id=99)
assert result.status == "inProgress"
assert result.result is None
async def test_404_raises_not_found(self) -> None:
routes = {("GET", "build/builds/9999"): (404, b'{"message": "Build not found"}')}
client = _make_client(routes)
with pytest.raises(NotFoundError):
await client.get_build_status(build_id=9999)
# ---------------------------------------------------------------------------
# get_release_approvals tests
# ---------------------------------------------------------------------------
class TestGetReleaseApprovals:
"""Tests for AzDoClient.get_release_approvals."""
async def test_returns_list_of_approval_records(self) -> None:
approvals_data = {
"value": [
{
"id": 101,
"status": "pending",
"releaseEnvironment": {"name": "Sandbox", "release": {"id": 55}},
},
{
"id": 102,
"status": "approved",
"releaseEnvironment": {"name": "Production", "release": {"id": 55}},
},
],
"count": 2,
}
routes = {("GET", "release/approvals"): (200, json.dumps(approvals_data))}
client = _make_client(routes)
result = await client.get_release_approvals(release_id=55)
assert isinstance(result, list)
assert len(result) == 2
assert all(isinstance(a, ApprovalRecord) for a in result)
async def test_approval_id_populated(self) -> None:
approvals_data = {
"value": [
{
"id": 201,
"status": "pending",
"releaseEnvironment": {"name": "Sandbox", "release": {"id": 10}},
}
],
"count": 1,
}
routes = {("GET", "release/approvals"): (200, json.dumps(approvals_data))}
client = _make_client(routes)
result = await client.get_release_approvals(release_id=10)
assert result[0].approval_id == "201"
async def test_stage_name_populated(self) -> None:
approvals_data = {
"value": [
{
"id": 300,
"status": "pending",
"releaseEnvironment": {"name": "Production", "release": {"id": 20}},
}
],
"count": 1,
}
routes = {("GET", "release/approvals"): (200, json.dumps(approvals_data))}
client = _make_client(routes)
result = await client.get_release_approvals(release_id=20)
assert result[0].stage_name == "Production"
async def test_release_id_populated(self) -> None:
approvals_data = {
"value": [
{
"id": 400,
"status": "pending",
"releaseEnvironment": {"name": "Stage", "release": {"id": 99}},
}
],
"count": 1,
}
routes = {("GET", "release/approvals"): (200, json.dumps(approvals_data))}
client = _make_client(routes)
result = await client.get_release_approvals(release_id=99)
assert result[0].release_id == 99
async def test_empty_list_when_no_approvals(self) -> None:
approvals_data = {"value": [], "count": 0}
routes = {("GET", "release/approvals"): (200, json.dumps(approvals_data))}
client = _make_client(routes)
result = await client.get_release_approvals(release_id=77)
assert result == []
async def test_filters_by_release_id_in_query(self) -> None:
captured_urls: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
captured_urls.append(str(request.url))
return httpx.Response(200, content=b'{"value": [], "count": 0}')
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
vsrm_client = httpx.AsyncClient(transport=transport)
client = AzDoClient(
base_url="https://dev.azure.com/my-org/my-project/_apis",
vsrm_base_url="https://vsrm.dev.azure.com/my-org/my-project/_apis",
pat="test-pat",
http_client=http_client,
vsrm_http_client=vsrm_client,
)
await client.get_release_approvals(release_id=42)
assert any("approvals" in url for url in captured_urls)
async def test_404_raises_not_found(self) -> None:
routes = {("GET", "release/approvals"): (404, b'{"message": "Not found"}')}
client = _make_client(routes)
with pytest.raises(NotFoundError):
await client.get_release_approvals(release_id=999)
# ---------------------------------------------------------------------------
# get_latest_release tests
# ---------------------------------------------------------------------------
class TestGetLatestRelease:
"""Tests for AzDoClient.get_latest_release."""
async def test_returns_dict(self) -> None:
release_data = {
"value": [{"id": 55, "name": "Release-55", "status": "active"}],
"count": 1,
}
routes = {("GET", "release/releases"): (200, json.dumps(release_data))}
client = _make_client(routes)
result = await client.get_latest_release(definition_id=7)
assert isinstance(result, dict)
assert result["id"] == 55
async def test_returns_empty_dict_when_no_releases(self) -> None:
release_data = {"value": [], "count": 0}
routes = {("GET", "release/releases"): (200, json.dumps(release_data))}
client = _make_client(routes)
result = await client.get_latest_release(definition_id=99)
assert result == {}
async def test_404_raises_not_found(self) -> None:
routes = {("GET", "release/releases"): (404, b'{"message": "Not found"}')}
client = _make_client(routes)
with pytest.raises(NotFoundError):
await client.get_latest_release(definition_id=999)
async def test_passes_definition_id_as_filter(self) -> None:
captured_urls: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
captured_urls.append(str(request.url))
return httpx.Response(200, content=b'{"value": [], "count": 0}')
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
vsrm_client = httpx.AsyncClient(transport=transport)
client = AzDoClient(
base_url="https://dev.azure.com/my-org/my-project/_apis",
vsrm_base_url="https://vsrm.dev.azure.com/my-org/my-project/_apis",
pat="test-pat",
http_client=http_client,
vsrm_http_client=vsrm_client,
)
await client.get_latest_release(definition_id=13)
assert any("releases" in url for url in captured_urls)
# ---------------------------------------------------------------------------
# approve_release tests
# ---------------------------------------------------------------------------
class TestApproveRelease:
"""Tests for AzDoClient.approve_release."""
async def test_returns_dict_with_status(self) -> None:
approve_data = _load_json("azdo_approve_release.json")
routes = {("PATCH", "release/approvals"): (200, json.dumps(approve_data))}
client = _make_client(routes)
result = await client.approve_release(
approval_id="approval-uuid-123", comment="Approved"
)
assert isinstance(result, dict)
assert result["status"] == "approved"
async def test_404_raises_not_found(self) -> None:
routes = {("PATCH", "release/approvals"): (404, b'{"message": "Approval not found"}')}
client = _make_client(routes)
with pytest.raises(NotFoundError):
await client.approve_release(approval_id="bad-id", comment="Approve")
# ---------------------------------------------------------------------------
# close() lifecycle tests
# ---------------------------------------------------------------------------
class TestAzDoClientLifecycle:
"""Tests for AzDoClient close() method."""
async def test_close_closes_both_clients(self) -> None:
transport = httpx.MockTransport(lambda r: httpx.Response(200, content=b"{}"))
http_client = httpx.AsyncClient(transport=transport)
vsrm_client = httpx.AsyncClient(transport=transport)
client = AzDoClient(
base_url="https://dev.azure.com/org/proj/_apis",
vsrm_base_url="https://vsrm.dev.azure.com/org/proj/_apis",
pat="my-pat",
http_client=http_client,
vsrm_http_client=vsrm_client,
)
await client.close()
assert http_client.is_closed
assert vsrm_client.is_closed
# ---------------------------------------------------------------------------
# list_active_prs tests
# ---------------------------------------------------------------------------
def _make_pr_list_response(prs: list[dict]) -> str:
return json.dumps({"value": prs, "count": len(prs)})
def _make_active_pr_item(
pr_id: int = 10,
title: str = "Test PR",
branch: str = "refs/heads/feature/ALLPOST-100_fix",
status: str = "active",
repo_name: str = "my-repo",
) -> dict:
return {
"pullRequestId": pr_id,
"title": title,
"status": status,
"sourceRefName": branch,
"targetRefName": "refs/heads/develop",
"url": f"https://dev.azure.com/org/proj/_apis/git/repositories/{repo_name}/pullRequests/{pr_id}",
"repository": {
"id": "repo-uuid",
"name": repo_name,
"remoteUrl": f"https://dev.azure.com/org/proj/_git/{repo_name}",
},
}
class TestListActivePrs:
"""Tests for AzDoClient.list_active_prs."""
async def test_returns_list_of_pr_info(self) -> None:
pr_item = _make_active_pr_item()
routes = {
("GET", "git/repositories/my-repo/pullRequests"): (
200,
_make_pr_list_response([pr_item]),
)
}
client = _make_client(routes)
result = await client.list_active_prs("my-repo", "refs/heads/develop")
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], PRInfo)
async def test_pr_id_extracted(self) -> None:
pr_item = _make_active_pr_item(pr_id=55)
routes = {
("GET", "git/repositories/my-repo/pullRequests"): (
200,
_make_pr_list_response([pr_item]),
)
}
client = _make_client(routes)
result = await client.list_active_prs("my-repo", "refs/heads/develop")
assert result[0].pr_id == "55"
async def test_pr_title_extracted(self) -> None:
pr_item = _make_active_pr_item(title="My Feature")
routes = {
("GET", "git/repositories/my-repo/pullRequests"): (
200,
_make_pr_list_response([pr_item]),
)
}
client = _make_client(routes)
result = await client.list_active_prs("my-repo", "refs/heads/develop")
assert result[0].pr_title == "My Feature"
async def test_empty_list_when_no_prs(self) -> None:
routes = {
("GET", "git/repositories/my-repo/pullRequests"): (
200,
_make_pr_list_response([]),
)
}
client = _make_client(routes)
result = await client.list_active_prs("my-repo", "refs/heads/develop")
assert result == []
async def test_multiple_prs_returned(self) -> None:
prs = [
_make_active_pr_item(pr_id=10, title="PR 10"),
_make_active_pr_item(pr_id=20, title="PR 20"),
]
routes = {
("GET", "git/repositories/my-repo/pullRequests"): (
200,
_make_pr_list_response(prs),
)
}
client = _make_client(routes)
result = await client.list_active_prs("my-repo", "refs/heads/develop")
assert len(result) == 2
assert {r.pr_id for r in result} == {"10", "20"}
async def test_404_raises_not_found(self) -> None:
routes = {
("GET", "git/repositories/missing-repo/pullRequests"): (
404,
b'{"message": "Repo not found"}',
)
}
client = _make_client(routes)
with pytest.raises(NotFoundError):
await client.list_active_prs("missing-repo", "refs/heads/develop")
async def test_401_raises_authentication_error(self) -> None:
routes = {
("GET", "git/repositories/my-repo/pullRequests"): (
401,
b'{"message": "Unauthorized"}',
)
}
client = _make_client(routes)
with pytest.raises(AuthenticationError):
await client.list_active_prs("my-repo", "refs/heads/develop")
async def test_500_raises_service_error(self) -> None:
routes = {
("GET", "git/repositories/my-repo/pullRequests"): (
500,
b'{"message": "Internal error"}',
)
}
client = _make_client(routes)
with pytest.raises(ServiceError):
await client.list_active_prs("my-repo", "refs/heads/develop")

View File

@@ -0,0 +1,454 @@
"""Tests for ClaudeReviewer using Claude Code CLI subprocess."""
import json
import pytest
from release_agent.models.review import ReviewResult
from release_agent.tools.claude_review import (
ClaudeReviewer,
_build_prompt,
_parse_cli_output,
_truncate_diff,
)
MAX_DIFF_CHARS = 100_000
# ---------------------------------------------------------------------------
# Helpers — fake subprocess runner
# ---------------------------------------------------------------------------
def _make_cli_output(
verdict: str = "approve",
summary: str = "LGTM",
issues: list | None = None,
) -> str:
"""Build a JSON string mimicking Claude Code CLI --output-format json."""
structured = {
"verdict": verdict,
"summary": summary,
"issues": issues or [],
}
return json.dumps({"result": "", "structured_output": structured})
def _make_subprocess_runner(
stdout: str = "",
stderr: str = "",
returncode: int = 0,
):
"""Return a fake run_subprocess callable that records calls."""
calls: list[dict] = []
async def fake_run(*, cmd, cwd, timeout):
calls.append({"cmd": cmd, "cwd": cwd, "timeout": timeout})
return (stdout, stderr, returncode)
return fake_run, calls
# ---------------------------------------------------------------------------
# _truncate_diff tests
# ---------------------------------------------------------------------------
class TestTruncateDiff:
def test_short_diff_not_truncated(self) -> None:
diff = "short diff"
assert _truncate_diff(diff) == diff
def test_exact_limit_not_truncated(self) -> None:
diff = "x" * MAX_DIFF_CHARS
assert _truncate_diff(diff) == diff
def test_over_limit_truncated(self) -> None:
diff = "x" * (MAX_DIFF_CHARS + 1000)
result = _truncate_diff(diff)
assert len(result) < len(diff)
assert "TRUNCATED" in result
# ---------------------------------------------------------------------------
# _build_prompt tests
# ---------------------------------------------------------------------------
class TestBuildPrompt:
def test_contains_pr_title(self) -> None:
prompt = _build_prompt(diff="d", pr_title="My Title", repo_name="repo")
assert "My Title" in prompt
def test_contains_repo_name(self) -> None:
prompt = _build_prompt(diff="d", pr_title="t", repo_name="my-repo")
assert "my-repo" in prompt
def test_contains_diff(self) -> None:
prompt = _build_prompt(diff="UNIQUE_DIFF", pr_title="t", repo_name="r")
assert "UNIQUE_DIFF" in prompt
# ---------------------------------------------------------------------------
# _parse_cli_output tests
# ---------------------------------------------------------------------------
class TestParseCliOutput:
def test_parses_structured_output(self) -> None:
stdout = _make_cli_output(verdict="approve", summary="Good")
result = _parse_cli_output(stdout)
assert isinstance(result, ReviewResult)
assert result.verdict == "approve"
assert result.summary == "Good"
def test_parses_request_changes(self) -> None:
stdout = _make_cli_output(
verdict="request_changes",
summary="Has issues",
issues=[{"severity": "blocker", "description": "SQL injection"}],
)
result = _parse_cli_output(stdout)
assert result.verdict == "request_changes"
assert len(result.issues) == 1
assert result.has_blockers is True
def test_parses_issues_with_optional_fields(self) -> None:
stdout = _make_cli_output(
verdict="request_changes",
summary="Issues found",
issues=[{
"severity": "warning",
"description": "Style issue",
"file_path": "src/foo.py",
"suggestion": "Fix it",
}],
)
result = _parse_cli_output(stdout)
assert result.issues[0].file_path == "src/foo.py"
assert result.issues[0].suggestion == "Fix it"
def test_empty_issues_no_blockers(self) -> None:
stdout = _make_cli_output(verdict="approve", summary="Clean", issues=[])
result = _parse_cli_output(stdout)
assert result.has_blockers is False
assert len(result.issues) == 0
def test_result_field_as_json_string(self) -> None:
"""When structured_output is absent, falls back to parsing result as JSON."""
inner = {"verdict": "approve", "summary": "OK", "issues": []}
stdout = json.dumps({"result": json.dumps(inner)})
result = _parse_cli_output(stdout)
assert result.verdict == "approve"
def test_invalid_json_raises(self) -> None:
with pytest.raises(ValueError, match="Failed to parse"):
_parse_cli_output("not json at all")
def test_missing_structured_output_and_result_raises(self) -> None:
with pytest.raises(ValueError, match="No structured_output"):
_parse_cli_output(json.dumps({"other": "data"}))
def test_non_dict_structured_output_raises(self) -> None:
stdout = json.dumps({"structured_output": ["not", "a", "dict"]})
with pytest.raises(ValueError, match="Expected dict"):
_parse_cli_output(stdout)
def test_result_is_non_json_string_raises(self) -> None:
stdout = json.dumps({"result": "just plain text, not json"})
with pytest.raises(ValueError, match="not valid JSON"):
_parse_cli_output(stdout)
# ---------------------------------------------------------------------------
# ClaudeReviewer construction tests
# ---------------------------------------------------------------------------
class TestClaudeReviewerConstruction:
def test_can_be_instantiated(self) -> None:
reviewer = ClaudeReviewer()
assert reviewer is not None
def test_custom_claude_cmd(self) -> None:
reviewer = ClaudeReviewer(claude_cmd="/usr/local/bin/claude")
assert reviewer._claude_cmd == "/usr/local/bin/claude"
def test_custom_timeout(self) -> None:
reviewer = ClaudeReviewer(timeout=60)
assert reviewer._timeout == 60
# ---------------------------------------------------------------------------
# review_pr tests
# ---------------------------------------------------------------------------
class TestReviewPr:
async def test_returns_review_result(self) -> None:
stdout = _make_cli_output(verdict="approve", summary="Looks good")
runner, _ = _make_subprocess_runner(stdout=stdout)
reviewer = ClaudeReviewer(run_subprocess=runner)
result = await reviewer.review_pr(
diff="diff --git a/foo.py ...",
pr_title="Fix bug",
repo_name="my-repo",
)
assert isinstance(result, ReviewResult)
assert result.verdict == "approve"
async def test_passes_cwd_to_subprocess(self) -> None:
stdout = _make_cli_output()
runner, calls = _make_subprocess_runner(stdout=stdout)
reviewer = ClaudeReviewer(run_subprocess=runner)
await reviewer.review_pr(
diff="diff",
pr_title="PR",
repo_name="repo",
cwd="/path/to/worktree",
)
assert calls[0]["cwd"] == "/path/to/worktree"
async def test_cmd_includes_claude_p(self) -> None:
stdout = _make_cli_output()
runner, calls = _make_subprocess_runner(stdout=stdout)
reviewer = ClaudeReviewer(run_subprocess=runner)
await reviewer.review_pr(diff="d", pr_title="t", repo_name="r")
cmd = calls[0]["cmd"]
assert cmd[0] == "claude"
assert "-p" in cmd
async def test_cmd_includes_output_format_json(self) -> None:
stdout = _make_cli_output()
runner, calls = _make_subprocess_runner(stdout=stdout)
reviewer = ClaudeReviewer(run_subprocess=runner)
await reviewer.review_pr(diff="d", pr_title="t", repo_name="r")
cmd = calls[0]["cmd"]
idx = cmd.index("--output-format")
assert cmd[idx + 1] == "json"
async def test_cmd_includes_json_schema(self) -> None:
stdout = _make_cli_output()
runner, calls = _make_subprocess_runner(stdout=stdout)
reviewer = ClaudeReviewer(run_subprocess=runner)
await reviewer.review_pr(diff="d", pr_title="t", repo_name="r")
cmd = calls[0]["cmd"]
assert "--json-schema" in cmd
async def test_cmd_includes_allowed_tools(self) -> None:
stdout = _make_cli_output()
runner, calls = _make_subprocess_runner(stdout=stdout)
reviewer = ClaudeReviewer(run_subprocess=runner)
await reviewer.review_pr(diff="d", pr_title="t", repo_name="r")
cmd = calls[0]["cmd"]
idx = cmd.index("--allowedTools")
assert "Read" in cmd[idx + 1]
async def test_cmd_includes_system_prompt(self) -> None:
stdout = _make_cli_output()
runner, calls = _make_subprocess_runner(stdout=stdout)
reviewer = ClaudeReviewer(run_subprocess=runner)
await reviewer.review_pr(diff="d", pr_title="t", repo_name="r")
cmd = calls[0]["cmd"]
assert "--system-prompt" in cmd
async def test_nonzero_exit_raises(self) -> None:
runner, _ = _make_subprocess_runner(
stdout="", stderr="error occurred", returncode=1
)
reviewer = ClaudeReviewer(run_subprocess=runner)
with pytest.raises(RuntimeError, match="exited with code 1"):
await reviewer.review_pr(diff="d", pr_title="t", repo_name="r")
async def test_timeout_passed_to_subprocess(self) -> None:
stdout = _make_cli_output()
runner, calls = _make_subprocess_runner(stdout=stdout)
reviewer = ClaudeReviewer(run_subprocess=runner, timeout=120)
await reviewer.review_pr(diff="d", pr_title="t", repo_name="r")
assert calls[0]["timeout"] == 120
async def test_pr_title_in_prompt(self) -> None:
stdout = _make_cli_output()
runner, calls = _make_subprocess_runner(stdout=stdout)
reviewer = ClaudeReviewer(run_subprocess=runner)
await reviewer.review_pr(
diff="d", pr_title="Specific Title", repo_name="r"
)
cmd = calls[0]["cmd"]
prompt = cmd[cmd.index("-p") + 1]
assert "Specific Title" in prompt
async def test_repo_name_in_prompt(self) -> None:
stdout = _make_cli_output()
runner, calls = _make_subprocess_runner(stdout=stdout)
reviewer = ClaudeReviewer(run_subprocess=runner)
await reviewer.review_pr(
diff="d", pr_title="t", repo_name="special-repo"
)
cmd = calls[0]["cmd"]
prompt = cmd[cmd.index("-p") + 1]
assert "special-repo" in prompt
async def test_cwd_none_when_not_provided(self) -> None:
stdout = _make_cli_output()
runner, calls = _make_subprocess_runner(stdout=stdout)
reviewer = ClaudeReviewer(run_subprocess=runner)
await reviewer.review_pr(diff="d", pr_title="t", repo_name="r")
assert calls[0]["cwd"] is None
async def test_request_changes_with_issues(self) -> None:
stdout = _make_cli_output(
verdict="request_changes",
summary="Problems found",
issues=[
{"severity": "blocker", "description": "Security flaw"},
{"severity": "warning", "description": "Missing docs"},
],
)
runner, _ = _make_subprocess_runner(stdout=stdout)
reviewer = ClaudeReviewer(run_subprocess=runner)
result = await reviewer.review_pr(diff="d", pr_title="t", repo_name="r")
assert result.verdict == "request_changes"
assert len(result.issues) == 2
assert result.has_blockers is True
# ---------------------------------------------------------------------------
# ClaudeReviewer.generate_ticket_content tests
# ---------------------------------------------------------------------------
def _make_ticket_cli_output(summary: str = "My summary", description: str = "My desc") -> str:
"""Build a JSON string mimicking Claude Code CLI output for ticket generation."""
structured = {"summary": summary, "description": description}
return json.dumps({"result": "", "structured_output": structured})
class TestGenerateTicketContent:
"""Tests for ClaudeReviewer.generate_ticket_content."""
async def test_returns_tuple_of_summary_and_description(self) -> None:
stdout = _make_ticket_cli_output(summary="Fix login bug", description="Detailed desc")
runner, _ = _make_subprocess_runner(stdout=stdout)
reviewer = ClaudeReviewer(run_subprocess=runner)
result = await reviewer.generate_ticket_content(
diff="edit: main.py", pr_title="Fix login", repo_name="backend"
)
assert isinstance(result, tuple)
assert len(result) == 2
async def test_returns_correct_summary(self) -> None:
stdout = _make_ticket_cli_output(summary="Implement OAuth2 login")
runner, _ = _make_subprocess_runner(stdout=stdout)
reviewer = ClaudeReviewer(run_subprocess=runner)
summary, _ = await reviewer.generate_ticket_content(
diff="d", pr_title="Add OAuth", repo_name="auth-service"
)
assert summary == "Implement OAuth2 login"
async def test_returns_correct_description(self) -> None:
stdout = _make_ticket_cli_output(description="This adds OAuth2 support for the login flow")
runner, _ = _make_subprocess_runner(stdout=stdout)
reviewer = ClaudeReviewer(run_subprocess=runner)
_, description = await reviewer.generate_ticket_content(
diff="d", pr_title="Add OAuth", repo_name="auth-service"
)
assert description == "This adds OAuth2 support for the login flow"
async def test_uses_json_schema_with_summary_and_description_fields(self) -> None:
stdout = _make_ticket_cli_output()
runner, calls = _make_subprocess_runner(stdout=stdout)
reviewer = ClaudeReviewer(run_subprocess=runner)
await reviewer.generate_ticket_content(diff="d", pr_title="t", repo_name="r")
cmd = calls[0]["cmd"]
# Verify --json-schema flag was used
assert "--json-schema" in cmd
schema_idx = cmd.index("--json-schema")
schema_json = cmd[schema_idx + 1]
schema = json.loads(schema_json)
assert "summary" in schema["properties"]
assert "description" in schema["properties"]
async def test_passes_pr_title_in_prompt(self) -> None:
stdout = _make_ticket_cli_output()
runner, calls = _make_subprocess_runner(stdout=stdout)
reviewer = ClaudeReviewer(run_subprocess=runner)
await reviewer.generate_ticket_content(
diff="d", pr_title="My Unique PR Title", repo_name="r"
)
cmd_str = " ".join(calls[0]["cmd"])
assert "My Unique PR Title" in cmd_str
async def test_passes_repo_name_in_prompt(self) -> None:
stdout = _make_ticket_cli_output()
runner, calls = _make_subprocess_runner(stdout=stdout)
reviewer = ClaudeReviewer(run_subprocess=runner)
await reviewer.generate_ticket_content(
diff="d", pr_title="t", repo_name="my-special-repo"
)
cmd_str = " ".join(calls[0]["cmd"])
assert "my-special-repo" in cmd_str
async def test_passes_cwd_to_subprocess(self) -> None:
stdout = _make_ticket_cli_output()
runner, calls = _make_subprocess_runner(stdout=stdout)
reviewer = ClaudeReviewer(run_subprocess=runner)
await reviewer.generate_ticket_content(
diff="d", pr_title="t", repo_name="r", cwd="/some/path"
)
assert calls[0]["cwd"] == "/some/path"
async def test_cwd_none_by_default(self) -> None:
stdout = _make_ticket_cli_output()
runner, calls = _make_subprocess_runner(stdout=stdout)
reviewer = ClaudeReviewer(run_subprocess=runner)
await reviewer.generate_ticket_content(diff="d", pr_title="t", repo_name="r")
assert calls[0]["cwd"] is None
async def test_raises_on_nonzero_exit_code(self) -> None:
runner, _ = _make_subprocess_runner(stdout="", stderr="Error", returncode=1)
reviewer = ClaudeReviewer(run_subprocess=runner)
with pytest.raises(RuntimeError, match="Claude CLI"):
await reviewer.generate_ticket_content(diff="d", pr_title="t", repo_name="r")
async def test_raises_on_invalid_json_output(self) -> None:
runner, _ = _make_subprocess_runner(stdout="not json at all")
reviewer = ClaudeReviewer(run_subprocess=runner)
with pytest.raises((ValueError, Exception)):
await reviewer.generate_ticket_content(diff="d", pr_title="t", repo_name="r")

205
tests/tools/test_http.py Normal file
View File

@@ -0,0 +1,205 @@
"""Tests for shared HTTP helpers. Written FIRST (TDD RED phase)."""
import base64
import httpx
import pytest
from release_agent.exceptions import (
AuthenticationError,
NotFoundError,
RateLimitError,
ServiceError,
ServiceUnavailableError,
)
from release_agent.tools._http import build_auth_header, raise_for_status
# ---------------------------------------------------------------------------
# raise_for_status tests
# ---------------------------------------------------------------------------
def _make_response(status_code: int, headers: dict | None = None) -> httpx.Response:
"""Build a minimal httpx.Response with the given status code."""
return httpx.Response(
status_code=status_code,
headers=headers or {},
content=b"{}",
request=httpx.Request("GET", "https://example.com"),
)
class TestRaiseForStatus:
"""Tests for raise_for_status helper."""
def test_2xx_does_not_raise(self) -> None:
for code in [200, 201, 204]:
response = _make_response(code)
# Should not raise anything
raise_for_status(response, service="test")
def test_401_raises_authentication_error(self) -> None:
response = _make_response(401)
with pytest.raises(AuthenticationError) as exc_info:
raise_for_status(response, service="azdo")
assert exc_info.value.service == "azdo"
assert exc_info.value.status_code == 401
def test_403_raises_authentication_error(self) -> None:
response = _make_response(403)
with pytest.raises(AuthenticationError) as exc_info:
raise_for_status(response, service="jira")
assert exc_info.value.status_code == 403
def test_404_raises_not_found_error(self) -> None:
response = _make_response(404)
with pytest.raises(NotFoundError) as exc_info:
raise_for_status(response, service="azdo")
assert exc_info.value.service == "azdo"
assert exc_info.value.status_code == 404
def test_429_raises_rate_limit_error(self) -> None:
response = _make_response(429)
with pytest.raises(RateLimitError) as exc_info:
raise_for_status(response, service="jira")
assert exc_info.value.status_code == 429
def test_429_with_retry_after_header_populates_retry_after(self) -> None:
response = _make_response(429, headers={"Retry-After": "60"})
with pytest.raises(RateLimitError) as exc_info:
raise_for_status(response, service="jira")
assert exc_info.value.retry_after == 60
def test_429_without_retry_after_header_retry_after_is_none(self) -> None:
response = _make_response(429)
with pytest.raises(RateLimitError) as exc_info:
raise_for_status(response, service="jira")
assert exc_info.value.retry_after is None
def test_503_raises_service_unavailable(self) -> None:
response = _make_response(503)
with pytest.raises(ServiceUnavailableError) as exc_info:
raise_for_status(response, service="slack")
assert exc_info.value.status_code == 503
def test_500_raises_service_error(self) -> None:
response = _make_response(500)
with pytest.raises(ServiceError) as exc_info:
raise_for_status(response, service="azdo")
assert exc_info.value.status_code == 500
assert exc_info.value.service == "azdo"
def test_400_raises_service_error(self) -> None:
response = _make_response(400)
with pytest.raises(ServiceError) as exc_info:
raise_for_status(response, service="jira")
assert exc_info.value.status_code == 400
def test_422_raises_service_error(self) -> None:
response = _make_response(422)
with pytest.raises(ServiceError):
raise_for_status(response, service="azdo")
def test_service_name_propagated_in_all_errors(self) -> None:
"""Each error type must carry the service name."""
cases = [
(401, AuthenticationError),
(404, NotFoundError),
(429, RateLimitError),
(503, ServiceUnavailableError),
(500, ServiceError),
]
for code, exc_type in cases:
response = _make_response(code)
with pytest.raises(exc_type) as exc_info:
raise_for_status(response, service="my-service")
assert exc_info.value.service == "my-service"
def test_3xx_does_not_raise(self) -> None:
"""Redirects are not errors (httpx follows them)."""
response = _make_response(301)
raise_for_status(response, service="test")
# ---------------------------------------------------------------------------
# build_auth_header tests
# ---------------------------------------------------------------------------
class TestBuildAuthHeader:
"""Tests for build_auth_header helper."""
def test_returns_authorization_key(self) -> None:
header = build_auth_header("user", "pass")
assert "Authorization" in header
def test_returns_basic_scheme(self) -> None:
header = build_auth_header("user", "pass")
assert header["Authorization"].startswith("Basic ")
def test_value_is_base64_encoded(self) -> None:
header = build_auth_header("user", "pass")
encoded_part = header["Authorization"].removeprefix("Basic ")
decoded = base64.b64decode(encoded_part).decode()
assert decoded == "user:pass"
def test_empty_username(self) -> None:
# PAT auth uses empty username with token as password
header = build_auth_header("", "my-token")
encoded_part = header["Authorization"].removeprefix("Basic ")
decoded = base64.b64decode(encoded_part).decode()
assert decoded == ":my-token"
def test_special_characters_in_password(self) -> None:
header = build_auth_header("user", "p@ss!#$%")
encoded_part = header["Authorization"].removeprefix("Basic ")
decoded = base64.b64decode(encoded_part).decode()
assert decoded == "user:p@ss!#$%"
def test_returns_dict(self) -> None:
result = build_auth_header("u", "p")
assert isinstance(result, dict)
def test_result_is_immutable_dict(self) -> None:
result = build_auth_header("u", "p")
# Ensure only the Authorization key is present
assert list(result.keys()) == ["Authorization"]
# ---------------------------------------------------------------------------
# Edge case coverage for _extract_detail and _parse_retry_after
# ---------------------------------------------------------------------------
class TestExtractDetailEdgeCases:
"""Tests for the private _extract_detail helper via raise_for_status."""
def test_non_dict_body_still_raises_service_error(self) -> None:
"""A JSON array body (non-dict) should still raise ServiceError."""
response = httpx.Response(
status_code=500,
content=b'["error", "list"]',
request=httpx.Request("GET", "https://example.com"),
)
with pytest.raises(ServiceError):
raise_for_status(response, service="test")
def test_invalid_json_body_still_raises(self) -> None:
"""A non-JSON response body should still raise ServiceError."""
response = httpx.Response(
status_code=500,
content=b"Internal Server Error (plain text)",
request=httpx.Request("GET", "https://example.com"),
)
with pytest.raises(ServiceError):
raise_for_status(response, service="test")
def test_429_with_non_integer_retry_after_retry_after_is_none(self) -> None:
"""A non-integer Retry-After value should result in retry_after=None."""
response = httpx.Response(
status_code=429,
headers={"Retry-After": "Wed, 21 Oct 2015 07:28:00 GMT"},
content=b"{}",
request=httpx.Request("GET", "https://example.com"),
)
with pytest.raises(RateLimitError) as exc_info:
raise_for_status(response, service="test")
assert exc_info.value.retry_after is None

572
tests/tools/test_jira.py Normal file
View File

@@ -0,0 +1,572 @@
"""Tests for JiraClient. Written FIRST (TDD RED phase)."""
import json
from pathlib import Path
import httpx
import pytest
from release_agent.exceptions import AuthenticationError, NotFoundError, ServiceError
from release_agent.models.jira import JiraIssue, JiraTransition
from release_agent.tools.jira import JiraClient
# ---------------------------------------------------------------------------
# Fixture helpers
# ---------------------------------------------------------------------------
FIXTURES = Path(__file__).parent / "fixtures"
def _load_json(name: str) -> dict:
return json.loads((FIXTURES / name).read_text())
def _make_transport(routes: dict[tuple[str, str], tuple[int, bytes | str]]) -> httpx.MockTransport:
"""Build a MockTransport dispatching by (method, url_substring)."""
def handler(request: httpx.Request) -> httpx.Response:
url = str(request.url)
method = request.method
for (m, url_fragment), (status, body) in routes.items():
if m == method and url_fragment in url:
content = body if isinstance(body, bytes) else body.encode()
return httpx.Response(status_code=status, content=content)
return httpx.Response(status_code=404, content=b'{"errorMessages": ["Not found"]}')
return httpx.MockTransport(handler)
def _make_client(routes: dict) -> JiraClient:
transport = _make_transport(routes)
http_client = httpx.AsyncClient(transport=transport)
return JiraClient(
base_url="https://billolife.atlassian.net",
email="user@example.com",
api_token="test-token",
http_client=http_client,
)
# ---------------------------------------------------------------------------
# Construction tests
# ---------------------------------------------------------------------------
class TestJiraClientConstruction:
"""Tests for JiraClient initialization."""
def test_can_be_instantiated(self) -> None:
transport = httpx.MockTransport(lambda r: httpx.Response(200, content=b"{}"))
http_client = httpx.AsyncClient(transport=transport)
client = JiraClient(
base_url="https://billolife.atlassian.net",
email="u@example.com",
api_token="token",
http_client=http_client,
)
assert client is not None
async def test_context_manager_closes_client(self) -> None:
transport = httpx.MockTransport(lambda r: httpx.Response(200, content=b"{}"))
http_client = httpx.AsyncClient(transport=transport)
async with JiraClient(
base_url="https://billolife.atlassian.net",
email="u@example.com",
api_token="token",
http_client=http_client,
) as client:
assert client is not None
assert http_client.is_closed
# ---------------------------------------------------------------------------
# get_issue tests
# ---------------------------------------------------------------------------
class TestGetIssue:
"""Tests for JiraClient.get_issue."""
async def test_returns_jira_issue(self) -> None:
issue_data = _load_json("jira_issue.json")
routes = {("GET", "ALLPOST-100"): (200, json.dumps(issue_data))}
client = _make_client(routes)
result = await client.get_issue("ALLPOST-100")
assert isinstance(result, JiraIssue)
async def test_key_extracted(self) -> None:
issue_data = _load_json("jira_issue.json")
routes = {("GET", "ALLPOST-100"): (200, json.dumps(issue_data))}
client = _make_client(routes)
result = await client.get_issue("ALLPOST-100")
assert result.key == "ALLPOST-100"
async def test_summary_extracted(self) -> None:
issue_data = _load_json("jira_issue.json")
routes = {("GET", "ALLPOST-100"): (200, json.dumps(issue_data))}
client = _make_client(routes)
result = await client.get_issue("ALLPOST-100")
assert result.summary == "Fix the authentication bug"
async def test_status_extracted(self) -> None:
issue_data = _load_json("jira_issue.json")
routes = {("GET", "ALLPOST-100"): (200, json.dumps(issue_data))}
client = _make_client(routes)
result = await client.get_issue("ALLPOST-100")
assert result.status == "In Progress"
async def test_404_raises_not_found(self) -> None:
routes = {("GET", "ALLPOST-999"): (404, b'{"errorMessages": ["Issue not found"]}')}
client = _make_client(routes)
with pytest.raises(NotFoundError):
await client.get_issue("ALLPOST-999")
async def test_401_raises_authentication_error(self) -> None:
routes = {("GET", "ALLPOST-100"): (401, b'{"errorMessages": ["Unauthorized"]}')}
client = _make_client(routes)
with pytest.raises(AuthenticationError):
await client.get_issue("ALLPOST-100")
# ---------------------------------------------------------------------------
# get_transitions tests
# ---------------------------------------------------------------------------
class TestGetTransitions:
"""Tests for JiraClient.get_transitions."""
async def test_returns_list_of_transitions(self) -> None:
transition_data = _load_json("jira_transitions.json")
routes = {("GET", "transitions"): (200, json.dumps(transition_data))}
client = _make_client(routes)
result = await client.get_transitions("ALLPOST-100")
assert isinstance(result, list)
assert all(isinstance(t, JiraTransition) for t in result)
async def test_transition_names_extracted(self) -> None:
transition_data = _load_json("jira_transitions.json")
routes = {("GET", "transitions"): (200, json.dumps(transition_data))}
client = _make_client(routes)
result = await client.get_transitions("ALLPOST-100")
names = [t.name for t in result]
assert "Released" in names
assert "In Progress" in names
async def test_transition_ids_extracted(self) -> None:
transition_data = _load_json("jira_transitions.json")
routes = {("GET", "transitions"): (200, json.dumps(transition_data))}
client = _make_client(routes)
result = await client.get_transitions("ALLPOST-100")
ids = [t.id for t in result]
assert "11" in ids
async def test_empty_transitions_returned(self) -> None:
routes = {("GET", "transitions"): (200, json.dumps({"transitions": []}))}
client = _make_client(routes)
result = await client.get_transitions("ALLPOST-100")
assert result == []
async def test_404_raises_not_found(self) -> None:
routes = {("GET", "transitions"): (404, b'{"errorMessages": ["Not found"]}')}
client = _make_client(routes)
with pytest.raises(NotFoundError):
await client.get_transitions("ALLPOST-999")
# ---------------------------------------------------------------------------
# transition_issue tests
# ---------------------------------------------------------------------------
class TestTransitionIssue:
"""Tests for JiraClient.transition_issue."""
async def test_returns_true_on_success(self) -> None:
transition_data = _load_json("jira_transitions.json")
routes = {
("GET", "transitions"): (200, json.dumps(transition_data)),
("POST", "transitions"): (204, b""),
}
client = _make_client(routes)
result = await client.transition_issue("ALLPOST-100", "Released")
assert result is True
async def test_returns_false_when_transition_not_found(self) -> None:
transition_data = _load_json("jira_transitions.json")
routes = {
("GET", "transitions"): (200, json.dumps(transition_data)),
("POST", "transitions"): (204, b""),
}
client = _make_client(routes)
# "QA Review" is not in the fixture transitions
result = await client.transition_issue("ALLPOST-100", "QA Review")
assert result is False
async def test_fallback_to_dev_in_progress_then_retries(self) -> None:
"""Two-step fallback: if target unavailable, try 'Dev in Progress' first."""
get_call_count = {"n": 0}
# First GET: only "Dev in Progress" available (no "Released" yet)
transition_data_first = {
"transitions": [{"id": "21", "name": "Dev in Progress"}]
}
# Second GET (after Dev in Progress transition): "Released" now available
transition_data_second = {
"transitions": [
{"id": "21", "name": "Dev in Progress"},
{"id": "41", "name": "Released"},
]
}
def handler(request: httpx.Request) -> httpx.Response:
url = str(request.url)
method = request.method
if method == "GET" and "transitions" in url:
get_call_count["n"] += 1
if get_call_count["n"] <= 1:
return httpx.Response(200, content=json.dumps(transition_data_first).encode())
return httpx.Response(200, content=json.dumps(transition_data_second).encode())
if method == "POST" and "transitions" in url:
return httpx.Response(204, content=b"")
return httpx.Response(404, content=b'{"errorMessages": ["Not found"]}')
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
client = JiraClient(
base_url="https://billolife.atlassian.net",
email="user@example.com",
api_token="test-token",
http_client=http_client,
)
result = await client.transition_issue("ALLPOST-100", "Released")
assert result is True
async def test_returns_false_when_still_unavailable_after_fallback(self) -> None:
"""Return False when target transition is unavailable even after fallback."""
get_call_count = {"n": 0}
# First GET: only "Dev in Progress" available
transition_data_first = {
"transitions": [{"id": "21", "name": "Dev in Progress"}]
}
# Second GET (after fallback): target STILL not available
transition_data_second = {
"transitions": [{"id": "21", "name": "Dev in Progress"}]
}
def handler(request: httpx.Request) -> httpx.Response:
url = str(request.url)
method = request.method
if method == "GET" and "transitions" in url:
get_call_count["n"] += 1
if get_call_count["n"] <= 1:
return httpx.Response(200, content=json.dumps(transition_data_first).encode())
return httpx.Response(200, content=json.dumps(transition_data_second).encode())
if method == "POST" and "transitions" in url:
return httpx.Response(204, content=b"")
return httpx.Response(404, content=b'{"errorMessages": ["Not found"]}')
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
client = JiraClient(
base_url="https://billolife.atlassian.net",
email="user@example.com",
api_token="test-token",
http_client=http_client,
)
result = await client.transition_issue("ALLPOST-100", "Released")
assert result is False
async def test_404_on_transition_raises_not_found(self) -> None:
transition_data = _load_json("jira_transitions.json")
routes = {
("GET", "transitions"): (200, json.dumps(transition_data)),
("POST", "transitions"): (404, b'{"errorMessages": ["Not found"]}'),
}
client = _make_client(routes)
with pytest.raises(NotFoundError):
await client.transition_issue("ALLPOST-100", "Released")
# ---------------------------------------------------------------------------
# add_remote_link tests
# ---------------------------------------------------------------------------
class TestAddRemoteLink:
"""Tests for JiraClient.add_remote_link."""
async def test_returns_true_on_success(self) -> None:
routes = {
("POST", "remotelink"): (200, json.dumps({"id": 1, "self": "..."})),
}
client = _make_client(routes)
result = await client.add_remote_link(
ticket_id="ALLPOST-100",
url="https://dev.azure.com/org/proj/_git/repo/pullrequest/42",
title="PR #42: Fix auth",
)
assert result is True
async def test_404_raises_not_found(self) -> None:
routes = {("POST", "remotelink"): (404, b'{"errorMessages": ["Not found"]}')}
client = _make_client(routes)
with pytest.raises(NotFoundError):
await client.add_remote_link(
ticket_id="ALLPOST-999",
url="https://example.com",
title="Some PR",
)
async def test_400_raises_service_error(self) -> None:
routes = {("POST", "remotelink"): (400, b'{"errorMessages": ["Bad request"]}')}
client = _make_client(routes)
with pytest.raises(ServiceError):
await client.add_remote_link(
ticket_id="ALLPOST-100",
url="not-a-url",
title="Bad link",
)
# ---------------------------------------------------------------------------
# Lifecycle tests
# ---------------------------------------------------------------------------
class TestJiraClientLifecycle:
"""Tests for JiraClient close() method."""
async def test_close_closes_http_client(self) -> None:
transport = httpx.MockTransport(lambda r: httpx.Response(200, content=b"{}"))
http_client = httpx.AsyncClient(transport=transport)
client = JiraClient(
base_url="https://billolife.atlassian.net",
email="u@example.com",
api_token="token",
http_client=http_client,
)
await client.close()
assert http_client.is_closed
# ---------------------------------------------------------------------------
# _text_to_adf tests
# ---------------------------------------------------------------------------
class TestTextToAdf:
"""Tests for the _text_to_adf helper."""
def test_returns_dict(self) -> None:
from release_agent.tools.jira import _text_to_adf
result = _text_to_adf("Hello world")
assert isinstance(result, dict)
def test_version_is_1(self) -> None:
from release_agent.tools.jira import _text_to_adf
result = _text_to_adf("Hello world")
assert result["version"] == 1
def test_type_is_doc(self) -> None:
from release_agent.tools.jira import _text_to_adf
result = _text_to_adf("Hello world")
assert result["type"] == "doc"
def test_content_is_list(self) -> None:
from release_agent.tools.jira import _text_to_adf
result = _text_to_adf("Hello world")
assert isinstance(result["content"], list)
def test_single_line_produces_one_paragraph(self) -> None:
from release_agent.tools.jira import _text_to_adf
result = _text_to_adf("Hello world")
assert len(result["content"]) == 1
assert result["content"][0]["type"] == "paragraph"
def test_multiline_produces_multiple_paragraphs(self) -> None:
from release_agent.tools.jira import _text_to_adf
result = _text_to_adf("Line one\n\nLine two")
# Each non-empty line becomes a paragraph
paragraphs = [c for c in result["content"] if c["type"] == "paragraph"]
assert len(paragraphs) == 2
def test_paragraph_contains_text_node(self) -> None:
from release_agent.tools.jira import _text_to_adf
result = _text_to_adf("Hello")
paragraph = result["content"][0]
assert "content" in paragraph
text_node = paragraph["content"][0]
assert text_node["type"] == "text"
assert text_node["text"] == "Hello"
def test_empty_string_produces_empty_doc(self) -> None:
from release_agent.tools.jira import _text_to_adf
result = _text_to_adf("")
assert result["content"] == []
# ---------------------------------------------------------------------------
# JiraClient.create_issue tests
# ---------------------------------------------------------------------------
class TestCreateIssue:
"""Tests for JiraClient.create_issue."""
async def test_returns_ticket_key(self) -> None:
response_body = json.dumps({"id": "10001", "key": "ALLPOST-42", "self": "https://..."})
routes = {("POST", "/rest/api/3/issue"): (201, response_body)}
client = _make_client(routes)
result = await client.create_issue(
project="ALLPOST",
summary="New feature",
description="Some description",
)
assert result == "ALLPOST-42"
async def test_default_issue_type_is_story(self) -> None:
captured_bodies: list[dict] = []
def handler(request: httpx.Request) -> httpx.Response:
captured_bodies.append(json.loads(request.content))
return httpx.Response(
201,
content=json.dumps({"key": "ALLPOST-1"}).encode(),
)
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
client = JiraClient(
base_url="https://billolife.atlassian.net",
email="u@example.com",
api_token="token",
http_client=http_client,
)
await client.create_issue(project="ALLPOST", summary="S", description="D")
assert captured_bodies[0]["fields"]["issuetype"]["name"] == "Story"
async def test_custom_issue_type_sent(self) -> None:
captured_bodies: list[dict] = []
def handler(request: httpx.Request) -> httpx.Response:
captured_bodies.append(json.loads(request.content))
return httpx.Response(
201,
content=json.dumps({"key": "ALLPOST-2"}).encode(),
)
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
client = JiraClient(
base_url="https://billolife.atlassian.net",
email="u@example.com",
api_token="token",
http_client=http_client,
)
await client.create_issue(
project="ALLPOST", summary="S", description="D", issue_type="Bug"
)
assert captured_bodies[0]["fields"]["issuetype"]["name"] == "Bug"
async def test_project_key_sent_in_body(self) -> None:
captured_bodies: list[dict] = []
def handler(request: httpx.Request) -> httpx.Response:
captured_bodies.append(json.loads(request.content))
return httpx.Response(
201,
content=json.dumps({"key": "MYPROJ-3"}).encode(),
)
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
client = JiraClient(
base_url="https://billolife.atlassian.net",
email="u@example.com",
api_token="token",
http_client=http_client,
)
await client.create_issue(project="MYPROJ", summary="S", description="D")
assert captured_bodies[0]["fields"]["project"]["key"] == "MYPROJ"
async def test_summary_sent_in_body(self) -> None:
captured_bodies: list[dict] = []
def handler(request: httpx.Request) -> httpx.Response:
captured_bodies.append(json.loads(request.content))
return httpx.Response(
201,
content=json.dumps({"key": "ALLPOST-5"}).encode(),
)
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
client = JiraClient(
base_url="https://billolife.atlassian.net",
email="u@example.com",
api_token="token",
http_client=http_client,
)
await client.create_issue(project="ALLPOST", summary="My Summary", description="D")
assert captured_bodies[0]["fields"]["summary"] == "My Summary"
async def test_description_is_adf_format(self) -> None:
captured_bodies: list[dict] = []
def handler(request: httpx.Request) -> httpx.Response:
captured_bodies.append(json.loads(request.content))
return httpx.Response(
201,
content=json.dumps({"key": "ALLPOST-6"}).encode(),
)
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
client = JiraClient(
base_url="https://billolife.atlassian.net",
email="u@example.com",
api_token="token",
http_client=http_client,
)
await client.create_issue(project="ALLPOST", summary="S", description="My desc")
desc = captured_bodies[0]["fields"]["description"]
assert desc["type"] == "doc"
assert desc["version"] == 1
async def test_401_raises_authentication_error(self) -> None:
routes = {("POST", "/rest/api/3/issue"): (401, b'{"errorMessages": ["Unauthorized"]}')}
client = _make_client(routes)
with pytest.raises(AuthenticationError):
await client.create_issue(project="ALLPOST", summary="S", description="D")
async def test_400_raises_service_error(self) -> None:
routes = {
("POST", "/rest/api/3/issue"): (
400,
json.dumps({"errorMessages": ["Bad request"], "errors": {}}).encode(),
)
}
client = _make_client(routes)
with pytest.raises(ServiceError):
await client.create_issue(project="ALLPOST", summary="S", description="D")

198
tests/tools/test_retry.py Normal file
View File

@@ -0,0 +1,198 @@
"""Tests for async retry decorator. Written FIRST (TDD RED phase)."""
import asyncio
import pytest
from release_agent.exceptions import (
NotFoundError,
RateLimitError,
ServiceError,
ServiceUnavailableError,
)
from release_agent.tools._retry import with_retry
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_failing_then_succeeding(failures: int, exc_factory, result="ok"):
"""Return an async callable that fails `failures` times then returns `result`."""
call_count = {"n": 0}
async def fn():
call_count["n"] += 1
if call_count["n"] <= failures:
raise exc_factory()
return result
return fn
# ---------------------------------------------------------------------------
# with_retry tests
# ---------------------------------------------------------------------------
class TestWithRetry:
"""Tests for the with_retry decorator."""
async def test_success_on_first_attempt(self) -> None:
call_count = {"n": 0}
@with_retry(max_attempts=3)
async def fn():
call_count["n"] += 1
return "done"
result = await fn()
assert result == "done"
assert call_count["n"] == 1
async def test_retries_on_rate_limit_error(self) -> None:
call_count = {"n": 0}
@with_retry(max_attempts=3, base_delay=0.0)
async def fn():
call_count["n"] += 1
if call_count["n"] < 3:
raise RateLimitError(service="jira", retry_after=None)
return "ok"
result = await fn()
assert result == "ok"
assert call_count["n"] == 3
async def test_retries_on_service_unavailable_error(self) -> None:
call_count = {"n": 0}
@with_retry(max_attempts=3, base_delay=0.0)
async def fn():
call_count["n"] += 1
if call_count["n"] < 2:
raise ServiceUnavailableError(service="azdo")
return "ok"
result = await fn()
assert result == "ok"
assert call_count["n"] == 2
async def test_does_not_retry_on_not_found_error(self) -> None:
call_count = {"n": 0}
@with_retry(max_attempts=3, base_delay=0.0)
async def fn():
call_count["n"] += 1
raise NotFoundError(service="azdo", detail="not found")
with pytest.raises(NotFoundError):
await fn()
assert call_count["n"] == 1
async def test_does_not_retry_on_generic_service_error(self) -> None:
call_count = {"n": 0}
@with_retry(max_attempts=3, base_delay=0.0)
async def fn():
call_count["n"] += 1
raise ServiceError(service="azdo", status_code=400, detail="bad request")
with pytest.raises(ServiceError):
await fn()
assert call_count["n"] == 1
async def test_raises_after_max_attempts_exceeded(self) -> None:
call_count = {"n": 0}
@with_retry(max_attempts=3, base_delay=0.0)
async def fn():
call_count["n"] += 1
raise RateLimitError(service="jira", retry_after=None)
with pytest.raises(RateLimitError):
await fn()
assert call_count["n"] == 3
async def test_max_attempts_one_means_no_retry(self) -> None:
call_count = {"n": 0}
@with_retry(max_attempts=1, base_delay=0.0)
async def fn():
call_count["n"] += 1
raise RateLimitError(service="jira", retry_after=None)
with pytest.raises(RateLimitError):
await fn()
assert call_count["n"] == 1
async def test_does_not_retry_on_non_release_agent_error(self) -> None:
call_count = {"n": 0}
@with_retry(max_attempts=3, base_delay=0.0)
async def fn():
call_count["n"] += 1
raise ValueError("unexpected")
with pytest.raises(ValueError):
await fn()
assert call_count["n"] == 1
async def test_respects_retry_after_from_rate_limit_error(self) -> None:
"""When retry_after is set, the decorator must wait at least that long."""
delays: list[float] = []
async def fake_sleep(seconds: float) -> None:
delays.append(seconds)
call_count = {"n": 0}
@with_retry(max_attempts=2, base_delay=0.0, sleep_fn=fake_sleep)
async def fn():
call_count["n"] += 1
if call_count["n"] < 2:
raise RateLimitError(service="jira", retry_after=5)
return "ok"
result = await fn()
assert result == "ok"
assert len(delays) == 1
assert delays[0] >= 5.0
async def test_exponential_backoff_grows(self) -> None:
"""Verify delays grow between retries (exponential)."""
delays: list[float] = []
async def fake_sleep(seconds: float) -> None:
delays.append(seconds)
call_count = {"n": 0}
@with_retry(max_attempts=4, base_delay=1.0, sleep_fn=fake_sleep)
async def fn():
call_count["n"] += 1
if call_count["n"] < 4:
raise ServiceUnavailableError(service="azdo")
return "ok"
await fn()
assert len(delays) == 3
# Each subsequent delay must not be less than the previous
assert delays[1] >= delays[0]
assert delays[2] >= delays[1]
async def test_preserves_return_value(self) -> None:
@with_retry(max_attempts=2, base_delay=0.0)
async def fn():
return {"key": "value"}
result = await fn()
assert result == {"key": "value"}
async def test_works_without_decorator_args_defaults(self) -> None:
"""Decorator used with defaults should still work."""
@with_retry()
async def fn():
return 42
result = await fn()
assert result == 42

755
tests/tools/test_slack.py Normal file
View File

@@ -0,0 +1,755 @@
"""Tests for SlackClient and Block Kit builders. Written FIRST (TDD RED phase)."""
import json
from datetime import date
import httpx
import pytest
from release_agent.exceptions import ServiceError
from release_agent.models.ticket import TicketEntry
from release_agent.tools.slack import (
SlackClient,
_build_approval_blocks,
_build_ci_status_blocks,
_build_interactive_approval_blocks,
_build_release_blocks,
_build_resolved_approval_blocks,
)
# ---------------------------------------------------------------------------
# Fixture helpers
# ---------------------------------------------------------------------------
def _make_ticket(ticket_id: str = "ALLPOST-100", summary: str = "Fix bug") -> TicketEntry:
return TicketEntry(
id=ticket_id,
summary=summary,
pr_id="PR-42",
pr_url="https://dev.azure.com/org/project/_git/repo/pullrequest/42",
pr_title="Fix bug PR",
branch=f"bug/{ticket_id}_fix-bug",
merged_at=date(2024, 1, 15),
)
def _make_transport(status: int = 200, body: bytes = b'{"ok": true}') -> httpx.MockTransport:
return httpx.MockTransport(lambda r: httpx.Response(status_code=status, content=body))
def _make_client(status: int = 200) -> SlackClient:
transport = _make_transport(status)
http_client = httpx.AsyncClient(transport=transport)
return SlackClient(
webhook_url="https://hooks.slack.com/services/T000/B000/xxxx",
http_client=http_client,
)
def _make_web_api_client(
status: int = 200,
body: bytes = b'{"ok": true, "ts": "1234567890.123456"}',
) -> SlackClient:
transport = httpx.MockTransport(lambda r: httpx.Response(status_code=status, content=body))
http_client = httpx.AsyncClient(transport=transport)
return SlackClient(
bot_token="xoxb-test-token",
channel_id="C12345678",
http_client=http_client,
)
# ---------------------------------------------------------------------------
# _build_release_blocks tests (pure function)
# ---------------------------------------------------------------------------
class TestBuildReleaseBlocks:
"""Tests for the _build_release_blocks pure function."""
def test_returns_list(self) -> None:
blocks = _build_release_blocks(
repo="my-repo",
version="v1.2.0",
release_date=date(2024, 1, 15),
tickets=[],
)
assert isinstance(blocks, list)
def test_has_at_least_one_block(self) -> None:
blocks = _build_release_blocks(
repo="my-repo",
version="v1.2.0",
release_date=date(2024, 1, 15),
tickets=[],
)
assert len(blocks) >= 1
def test_repo_name_present_in_blocks(self) -> None:
blocks = _build_release_blocks(
repo="my-repo",
version="v1.2.0",
release_date=date(2024, 1, 15),
tickets=[],
)
text = json.dumps(blocks)
assert "my-repo" in text
def test_version_present_in_blocks(self) -> None:
blocks = _build_release_blocks(
repo="my-repo",
version="v1.2.0",
release_date=date(2024, 1, 15),
tickets=[],
)
text = json.dumps(blocks)
assert "v1.2.0" in text
def test_release_date_present_in_blocks(self) -> None:
blocks = _build_release_blocks(
repo="my-repo",
version="v1.2.0",
release_date=date(2024, 1, 15),
tickets=[],
)
text = json.dumps(blocks)
assert "2024" in text
def test_ticket_ids_present_in_blocks(self) -> None:
tickets = [_make_ticket("ALLPOST-100"), _make_ticket("ALLPOST-200")]
blocks = _build_release_blocks(
repo="my-repo",
version="v1.2.0",
release_date=date(2024, 1, 15),
tickets=tickets,
)
text = json.dumps(blocks)
assert "ALLPOST-100" in text
assert "ALLPOST-200" in text
def test_empty_tickets_still_valid(self) -> None:
blocks = _build_release_blocks(
repo="my-repo",
version="v1.2.0",
release_date=date(2024, 1, 15),
tickets=[],
)
assert len(blocks) >= 1
def test_blocks_are_dicts(self) -> None:
blocks = _build_release_blocks(
repo="my-repo",
version="v1.2.0",
release_date=date(2024, 1, 15),
tickets=[_make_ticket()],
)
assert all(isinstance(b, dict) for b in blocks)
def test_each_block_has_type_key(self) -> None:
blocks = _build_release_blocks(
repo="my-repo",
version="v1.2.0",
release_date=date(2024, 1, 15),
tickets=[],
)
for block in blocks:
assert "type" in block
def test_ticket_summaries_included(self) -> None:
tickets = [_make_ticket("ALLPOST-100", "Fix the auth bug")]
blocks = _build_release_blocks(
repo="my-repo",
version="v1.2.0",
release_date=date(2024, 1, 15),
tickets=tickets,
)
text = json.dumps(blocks)
assert "Fix the auth bug" in text
# ---------------------------------------------------------------------------
# _build_approval_blocks tests (pure function)
# ---------------------------------------------------------------------------
class TestBuildApprovalBlocks:
"""Tests for the _build_approval_blocks pure function."""
def test_returns_list(self) -> None:
blocks = _build_approval_blocks(
action="Deploy to Production",
details="v1.2.0 for my-repo",
approval_url="https://dev.azure.com/approve/123",
)
assert isinstance(blocks, list)
def test_has_at_least_one_block(self) -> None:
blocks = _build_approval_blocks(
action="Deploy",
details="v1.0.0",
approval_url="https://example.com",
)
assert len(blocks) >= 1
def test_action_present_in_blocks(self) -> None:
blocks = _build_approval_blocks(
action="Deploy to Production",
details="v1.2.0",
approval_url="https://example.com",
)
text = json.dumps(blocks)
assert "Deploy to Production" in text
def test_details_present_in_blocks(self) -> None:
blocks = _build_approval_blocks(
action="Deploy",
details="version v1.2.0 of my-repo",
approval_url="https://example.com",
)
text = json.dumps(blocks)
assert "version v1.2.0 of my-repo" in text
def test_approval_url_present_in_blocks(self) -> None:
blocks = _build_approval_blocks(
action="Deploy",
details="details",
approval_url="https://dev.azure.com/approve/abc",
)
text = json.dumps(blocks)
assert "https://dev.azure.com/approve/abc" in text
def test_blocks_are_dicts(self) -> None:
blocks = _build_approval_blocks(
action="Deploy",
details="details",
approval_url="https://example.com",
)
assert all(isinstance(b, dict) for b in blocks)
def test_each_block_has_type_key(self) -> None:
blocks = _build_approval_blocks(
action="Deploy",
details="details",
approval_url="https://example.com",
)
for block in blocks:
assert "type" in block
# ---------------------------------------------------------------------------
# SlackClient.send_release_notification tests
# ---------------------------------------------------------------------------
class TestSendReleaseNotification:
"""Tests for SlackClient.send_release_notification."""
async def test_returns_true_on_success(self) -> None:
client = _make_client(status=200)
result = await client.send_release_notification(
repo="my-repo",
version="v1.2.0",
release_date=date(2024, 1, 15),
tickets=[_make_ticket()],
)
assert result is True
async def test_returns_true_with_empty_tickets(self) -> None:
client = _make_client(status=200)
result = await client.send_release_notification(
repo="my-repo",
version="v1.2.0",
release_date=date(2024, 1, 15),
tickets=[],
)
assert result is True
async def test_500_raises_service_error(self) -> None:
client = _make_client(status=500)
with pytest.raises(ServiceError):
await client.send_release_notification(
repo="my-repo",
version="v1.2.0",
release_date=date(2024, 1, 15),
tickets=[],
)
async def test_sends_post_request(self) -> None:
requests_captured: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
requests_captured.append(request)
return httpx.Response(200, content=b'{"ok": true}')
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
client = SlackClient(
webhook_url="https://hooks.slack.com/services/T000/B000/xxxx",
http_client=http_client,
)
await client.send_release_notification(
repo="my-repo",
version="v1.2.0",
release_date=date(2024, 1, 15),
tickets=[],
)
assert len(requests_captured) == 1
assert requests_captured[0].method == "POST"
# ---------------------------------------------------------------------------
# SlackClient.send_approval_request tests
# ---------------------------------------------------------------------------
class TestSendApprovalRequest:
"""Tests for SlackClient.send_approval_request."""
async def test_returns_true_on_success(self) -> None:
client = _make_client(status=200)
result = await client.send_approval_request(
action="Deploy to Production",
details="v1.2.0 for my-repo",
approval_url="https://dev.azure.com/approve/123",
)
assert result is True
async def test_500_raises_service_error(self) -> None:
client = _make_client(status=500)
with pytest.raises(ServiceError):
await client.send_approval_request(
action="Deploy",
details="v1.0.0",
approval_url="https://example.com",
)
# ---------------------------------------------------------------------------
# SlackClient lifecycle tests
# ---------------------------------------------------------------------------
class TestSlackClientLifecycle:
"""Tests for SlackClient close() and context manager."""
async def test_close_closes_http_client(self) -> None:
transport = _make_transport()
http_client = httpx.AsyncClient(transport=transport)
client = SlackClient(
webhook_url="https://hooks.slack.com/services/T000/B000/xxxx",
http_client=http_client,
)
await client.close()
assert http_client.is_closed
async def test_context_manager_closes_client(self) -> None:
transport = _make_transport()
http_client = httpx.AsyncClient(transport=transport)
async with SlackClient(
webhook_url="https://hooks.slack.com/services/T000/B000/xxxx",
http_client=http_client,
) as client:
assert client is not None
assert http_client.is_closed
# ---------------------------------------------------------------------------
# SlackClient dual-mode construction tests
# ---------------------------------------------------------------------------
class TestSlackClientDualMode:
"""Tests for dual-mode SlackClient (webhook vs Web API)."""
def test_can_be_created_with_webhook_only(self) -> None:
transport = _make_transport()
http_client = httpx.AsyncClient(transport=transport)
client = SlackClient(
webhook_url="https://hooks.slack.com/services/T000/B000/xxxx",
http_client=http_client,
)
assert client is not None
def test_can_be_created_with_bot_token_and_channel(self) -> None:
transport = _make_transport()
http_client = httpx.AsyncClient(transport=transport)
client = SlackClient(
bot_token="xoxb-test",
channel_id="C12345",
http_client=http_client,
)
assert client is not None
def test_can_be_created_with_all_params(self) -> None:
transport = _make_transport()
http_client = httpx.AsyncClient(transport=transport)
client = SlackClient(
webhook_url="https://hooks.slack.com/services/T000/B000/xxxx",
bot_token="xoxb-test",
channel_id="C12345",
http_client=http_client,
)
assert client is not None
def test_can_be_created_with_no_url_params(self) -> None:
transport = _make_transport()
http_client = httpx.AsyncClient(transport=transport)
client = SlackClient(http_client=http_client)
assert client is not None
# ---------------------------------------------------------------------------
# SlackClient.send_interactive_approval tests
# ---------------------------------------------------------------------------
class TestSendInteractiveApproval:
"""Tests for SlackClient.send_interactive_approval."""
async def test_returns_message_ts_on_success(self) -> None:
client = _make_web_api_client()
result = await client.send_interactive_approval(
thread_id="thread-abc",
action="Deploy to Sandbox",
details="Release v1.0.0 of my-repo",
buttons=[{"text": "Approve", "value": "approve"}, {"text": "Reject", "value": "reject"}],
)
assert isinstance(result, str)
assert result == "1234567890.123456"
async def test_returns_empty_string_on_api_error(self) -> None:
client = _make_web_api_client(status=200, body=b'{"ok": false, "error": "channel_not_found"}')
result = await client.send_interactive_approval(
thread_id="thread-abc",
action="Deploy",
details="v1.0.0",
buttons=[],
)
assert result == ""
async def test_posts_to_chat_postmessage(self) -> None:
captured_urls: list[str] = []
captured_bodies: list[dict] = []
def handler(request: httpx.Request) -> httpx.Response:
captured_urls.append(str(request.url))
captured_bodies.append(json.loads(request.content))
return httpx.Response(200, content=b'{"ok": true, "ts": "111.222"}')
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
client = SlackClient(
bot_token="xoxb-test",
channel_id="C99999",
http_client=http_client,
)
await client.send_interactive_approval(
thread_id="thread-xyz",
action="Deploy",
details="details",
buttons=[],
)
assert any("chat.postMessage" in url for url in captured_urls)
assert captured_bodies[0]["channel"] == "C99999"
async def test_includes_thread_id_in_blocks(self) -> None:
captured_bodies: list[dict] = []
def handler(request: httpx.Request) -> httpx.Response:
captured_bodies.append(json.loads(request.content))
return httpx.Response(200, content=b'{"ok": true, "ts": "111.222"}')
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
client = SlackClient(bot_token="xoxb-test", channel_id="C1", http_client=http_client)
await client.send_interactive_approval(
thread_id="my-thread-id",
action="Deploy",
details="v1.0.0",
buttons=[{"text": "Approve", "value": "approve"}],
)
body_str = json.dumps(captured_bodies)
assert "my-thread-id" in body_str
# ---------------------------------------------------------------------------
# SlackClient.update_message tests
# ---------------------------------------------------------------------------
class TestUpdateMessage:
"""Tests for SlackClient.update_message."""
async def test_returns_true_on_success(self) -> None:
client = _make_web_api_client(body=b'{"ok": true}')
result = await client.update_message(
message_ts="1234567890.123456",
text="Updated message",
blocks=[],
)
assert result is True
async def test_returns_false_on_api_error(self) -> None:
client = _make_web_api_client(body=b'{"ok": false, "error": "message_not_found"}')
result = await client.update_message(
message_ts="bad-ts",
text="Update",
blocks=[],
)
assert result is False
async def test_posts_to_chat_update(self) -> None:
captured_urls: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
captured_urls.append(str(request.url))
return httpx.Response(200, content=b'{"ok": true}')
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
client = SlackClient(bot_token="xoxb-test", channel_id="C1", http_client=http_client)
await client.update_message(message_ts="ts-abc", text="Hello", blocks=[])
assert any("chat.update" in url for url in captured_urls)
# ---------------------------------------------------------------------------
# SlackClient.send_notification tests
# ---------------------------------------------------------------------------
class TestSendNotification:
"""Tests for SlackClient.send_notification."""
async def test_returns_true_via_web_api(self) -> None:
client = _make_web_api_client(body=b'{"ok": true, "ts": "111.222"}')
result = await client.send_notification(text="Build passed", blocks=[])
assert result is True
async def test_returns_true_via_webhook(self) -> None:
client = _make_client(status=200)
result = await client.send_notification(text="Build passed", blocks=[])
assert result is True
async def test_returns_false_on_web_api_error(self) -> None:
client = _make_web_api_client(body=b'{"ok": false, "error": "invalid_auth"}')
result = await client.send_notification(text="Build passed", blocks=[])
assert result is False
# ---------------------------------------------------------------------------
# _build_interactive_approval_blocks tests (pure function)
# ---------------------------------------------------------------------------
class TestBuildInteractiveApprovalBlocks:
"""Tests for _build_interactive_approval_blocks pure function."""
def test_returns_list(self) -> None:
blocks = _build_interactive_approval_blocks(
thread_id="t1",
action="Deploy to Sandbox",
details="v1.0.0",
buttons=[{"text": "Approve", "value": "approve"}],
)
assert isinstance(blocks, list)
def test_has_at_least_one_block(self) -> None:
blocks = _build_interactive_approval_blocks(
thread_id="t1",
action="Deploy",
details="details",
buttons=[],
)
assert len(blocks) >= 1
def test_action_present_in_blocks(self) -> None:
blocks = _build_interactive_approval_blocks(
thread_id="t1",
action="Deploy to Production",
details="v1.2.0",
buttons=[],
)
text = json.dumps(blocks)
assert "Deploy to Production" in text
def test_thread_id_in_button_value(self) -> None:
blocks = _build_interactive_approval_blocks(
thread_id="my-unique-thread",
action="Deploy",
details="details",
buttons=[{"text": "Approve", "value": "approve"}],
)
text = json.dumps(blocks)
assert "my-unique-thread" in text
def test_buttons_render_as_actions_block(self) -> None:
blocks = _build_interactive_approval_blocks(
thread_id="t1",
action="Deploy",
details="details",
buttons=[
{"text": "Approve", "value": "approve"},
{"text": "Reject", "value": "reject"},
],
)
block_types = [b["type"] for b in blocks]
assert "actions" in block_types
def test_empty_buttons_still_valid(self) -> None:
blocks = _build_interactive_approval_blocks(
thread_id="t1",
action="Deploy",
details="details",
buttons=[],
)
assert isinstance(blocks, list)
def test_details_present_in_blocks(self) -> None:
blocks = _build_interactive_approval_blocks(
thread_id="t1",
action="Deploy",
details="Release v2.0.0 of my-service",
buttons=[],
)
text = json.dumps(blocks)
assert "Release v2.0.0 of my-service" in text
# ---------------------------------------------------------------------------
# _build_ci_status_blocks tests (pure function)
# ---------------------------------------------------------------------------
class TestBuildCiStatusBlocks:
"""Tests for _build_ci_status_blocks pure function."""
def test_returns_list(self) -> None:
blocks = _build_ci_status_blocks(
repo="my-repo",
branch="main",
status="succeeded",
build_url="https://dev.azure.com/org/proj/_build/results?buildId=42",
)
assert isinstance(blocks, list)
def test_repo_present(self) -> None:
blocks = _build_ci_status_blocks(
repo="my-service",
branch="main",
status="succeeded",
build_url=None,
)
text = json.dumps(blocks)
assert "my-service" in text
def test_branch_present(self) -> None:
blocks = _build_ci_status_blocks(
repo="my-repo",
branch="release/v1.0.0",
status="succeeded",
build_url=None,
)
text = json.dumps(blocks)
assert "release/v1.0.0" in text
def test_status_present(self) -> None:
blocks = _build_ci_status_blocks(
repo="my-repo",
branch="main",
status="failed",
build_url=None,
)
text = json.dumps(blocks)
assert "failed" in text
def test_build_url_present_when_provided(self) -> None:
url = "https://dev.azure.com/org/proj/_build/results?buildId=99"
blocks = _build_ci_status_blocks(
repo="my-repo",
branch="main",
status="succeeded",
build_url=url,
)
text = json.dumps(blocks)
assert url in text
def test_build_url_none_does_not_crash(self) -> None:
blocks = _build_ci_status_blocks(
repo="my-repo",
branch="main",
status="succeeded",
build_url=None,
)
assert isinstance(blocks, list)
def test_all_blocks_are_dicts(self) -> None:
blocks = _build_ci_status_blocks(
repo="my-repo",
branch="main",
status="succeeded",
build_url=None,
)
assert all(isinstance(b, dict) for b in blocks)
# ---------------------------------------------------------------------------
# _build_resolved_approval_blocks tests (pure function)
# ---------------------------------------------------------------------------
class TestBuildResolvedApprovalBlocks:
"""Tests for _build_resolved_approval_blocks pure function."""
def test_returns_list(self) -> None:
blocks = _build_resolved_approval_blocks(
action="Deploy to Sandbox",
outcome="approved",
user="alice",
)
assert isinstance(blocks, list)
def test_action_present(self) -> None:
blocks = _build_resolved_approval_blocks(
action="Deploy to Production",
outcome="approved",
user="alice",
)
text = json.dumps(blocks)
assert "Deploy to Production" in text
def test_outcome_present(self) -> None:
blocks = _build_resolved_approval_blocks(
action="Deploy",
outcome="rejected",
user="bob",
)
text = json.dumps(blocks)
assert "rejected" in text
def test_user_present(self) -> None:
blocks = _build_resolved_approval_blocks(
action="Deploy",
outcome="approved",
user="charlie",
)
text = json.dumps(blocks)
assert "charlie" in text
def test_all_blocks_are_dicts(self) -> None:
blocks = _build_resolved_approval_blocks(
action="Deploy",
outcome="approved",
user="dave",
)
assert all(isinstance(b, dict) for b in blocks)