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:
0
tests/tools/__init__.py
Normal file
0
tests/tools/__init__.py
Normal file
9
tests/tools/fixtures/azdo_approve_release.json
Normal file
9
tests/tools/fixtures/azdo_approve_release.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "approval-uuid-123",
|
||||
"status": "approved",
|
||||
"approver": {
|
||||
"id": "user-uuid-456",
|
||||
"displayName": "Release Bot"
|
||||
},
|
||||
"comments": "Approved via release agent"
|
||||
}
|
||||
9
tests/tools/fixtures/azdo_build_status.json
Normal file
9
tests/tools/fixtures/azdo_build_status.json
Normal 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"
|
||||
}
|
||||
8
tests/tools/fixtures/azdo_create_pr.json
Normal file
8
tests/tools/fixtures/azdo_create_pr.json
Normal 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"
|
||||
}
|
||||
8
tests/tools/fixtures/azdo_merge_pr.json
Normal file
8
tests/tools/fixtures/azdo_merge_pr.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"pullRequestId": 42,
|
||||
"status": "completed",
|
||||
"title": "Fix the auth bug",
|
||||
"completionOptions": {
|
||||
"mergeStrategy": "squash"
|
||||
}
|
||||
}
|
||||
15
tests/tools/fixtures/azdo_pipelines.json
Normal file
15
tests/tools/fixtures/azdo_pipelines.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"value": [
|
||||
{
|
||||
"id": 10,
|
||||
"name": "Release Pipeline",
|
||||
"folder": "\\"
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"name": "Build Pipeline",
|
||||
"folder": "\\"
|
||||
}
|
||||
],
|
||||
"count": 2
|
||||
}
|
||||
16
tests/tools/fixtures/azdo_pr.json
Normal file
16
tests/tools/fixtures/azdo_pr.json
Normal 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"
|
||||
}
|
||||
}
|
||||
11
tests/tools/fixtures/azdo_pr_diff.txt
Normal file
11
tests/tools/fixtures/azdo_pr_diff.txt
Normal 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 ")
|
||||
11
tests/tools/fixtures/azdo_trigger_pipeline.json
Normal file
11
tests/tools/fixtures/azdo_trigger_pipeline.json
Normal 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"
|
||||
}
|
||||
10
tests/tools/fixtures/jira_issue.json
Normal file
10
tests/tools/fixtures/jira_issue.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "12345",
|
||||
"key": "ALLPOST-100",
|
||||
"fields": {
|
||||
"summary": "Fix the authentication bug",
|
||||
"status": {
|
||||
"name": "In Progress"
|
||||
}
|
||||
}
|
||||
}
|
||||
20
tests/tools/fixtures/jira_transitions.json
Normal file
20
tests/tools/fixtures/jira_transitions.json
Normal 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
819
tests/tools/test_azdo.py
Normal 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")
|
||||
454
tests/tools/test_claude_review.py
Normal file
454
tests/tools/test_claude_review.py
Normal 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
205
tests/tools/test_http.py
Normal 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
572
tests/tools/test_jira.py
Normal 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
198
tests/tools/test_retry.py
Normal 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
755
tests/tools/test_slack.py
Normal 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)
|
||||
Reference in New Issue
Block a user