feat: initial commit — Billo Release Agent (LangGraph)

LangGraph-based release automation agent with:
- PR discovery (webhook + polling)
- AI code review via Claude Code CLI (subscription-based)
- Auto-create Jira tickets for PRs without ticket ID
- Jira ticket lifecycle management (code review -> staging -> done)
- CI/CD pipeline trigger, polling, and approval gates
- Slack interactive messages with approval buttons
- Per-repo semantic versioning
- PostgreSQL persistence (threads, staging, releases)
- FastAPI API (webhooks, approvals, status, manual triggers)
- Docker Compose deployment

1069 tests, 95%+ coverage.
This commit is contained in:
Yaojia Wang
2026-03-24 17:38:23 +01:00
commit f5c2733cfb
104 changed files with 19721 additions and 0 deletions

218
tests/api/test_webhooks.py Normal file
View File

@@ -0,0 +1,218 @@
"""Tests for webhook endpoint. Written FIRST (TDD RED phase)."""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from release_agent.api.webhooks import (
_validate_webhook_secret,
router as webhook_router,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
VALID_SECRET = "super-secret-webhook-key"
_COMPLETED_PR_PAYLOAD = {
"subscription_id": "sub-1",
"event_type": "git.pullrequest.updated",
"resource": {
"repository": {
"id": "repo-1",
"name": "my-repo",
"web_url": "https://dev.azure.com/org/project/_git/my-repo",
},
"pull_request_id": 42,
"title": "feat: add feature",
"source_ref_name": "refs/heads/feature/BILL-123-add-feature",
"target_ref_name": "refs/heads/main",
"status": "completed",
"closed_date": "2024-01-15T10:00:00Z",
},
}
_ACTIVE_PR_PAYLOAD = {
"subscription_id": "sub-2",
"event_type": "git.pullrequest.updated",
"resource": {
"repository": {
"id": "repo-1",
"name": "my-repo",
"web_url": "https://dev.azure.com/org/project/_git/my-repo",
},
"pull_request_id": 43,
"title": "WIP: work in progress",
"source_ref_name": "refs/heads/feature/BILL-456",
"target_ref_name": "refs/heads/main",
"status": "active",
"closed_date": None,
},
}
def _make_test_app(webhook_secret: str = VALID_SECRET) -> FastAPI:
"""Return a FastAPI app with mocked state for webhook tests."""
app = FastAPI()
app.include_router(webhook_router)
mock_settings = MagicMock()
mock_settings.webhook_secret.get_secret_value.return_value = webhook_secret
mock_graphs = {
"pr_completed": MagicMock(),
"release": MagicMock(),
}
mock_clients = MagicMock()
mock_pool = MagicMock()
# background_tasks set tracked on state
app.state.settings = mock_settings
app.state.graphs = mock_graphs
app.state.tool_clients = mock_clients
app.state.db_pool = mock_pool
app.state.background_tasks = set()
return app
# ---------------------------------------------------------------------------
# _validate_webhook_secret (unit tests, pure function)
# ---------------------------------------------------------------------------
class TestValidateWebhookSecret:
def test_valid_secret_returns_true(self) -> None:
assert _validate_webhook_secret("mysecret", "mysecret") is True
def test_wrong_secret_returns_false(self) -> None:
assert _validate_webhook_secret("wrong", "mysecret") is False
def test_empty_header_returns_false(self) -> None:
assert _validate_webhook_secret("", "mysecret") is False
def test_none_header_returns_false(self) -> None:
assert _validate_webhook_secret(None, "mysecret") is False # type: ignore[arg-type]
def test_uses_constant_time_comparison(self) -> None:
# Should not raise even for very different lengths
assert _validate_webhook_secret("a", "very-long-secret-value") is False
def test_empty_expected_rejects_all(self) -> None:
# Empty expected secret = auth misconfigured, reject everything
assert _validate_webhook_secret("", "") is False
assert _validate_webhook_secret("any-value", "") is False
assert _validate_webhook_secret(None, "") is False
# ---------------------------------------------------------------------------
# POST /webhooks/azdo — integration tests via TestClient
# ---------------------------------------------------------------------------
class TestWebhookEndpoint:
def test_valid_completed_pr_returns_202(self) -> None:
app = _make_test_app()
with patch(
"release_agent.api.webhooks.asyncio.create_task", return_value=MagicMock()
):
with TestClient(app, raise_server_exceptions=True) as client:
response = client.post(
"/webhooks/azdo",
json=_COMPLETED_PR_PAYLOAD,
headers={"X-Webhook-Secret": VALID_SECRET},
)
assert response.status_code == 202
data = response.json()
assert "thread_id" in data
assert "message" in data
def test_missing_secret_header_returns_401(self) -> None:
app = _make_test_app()
with TestClient(app) as client:
response = client.post(
"/webhooks/azdo",
json=_COMPLETED_PR_PAYLOAD,
)
assert response.status_code == 401
def test_wrong_secret_header_returns_401(self) -> None:
app = _make_test_app()
with TestClient(app) as client:
response = client.post(
"/webhooks/azdo",
json=_COMPLETED_PR_PAYLOAD,
headers={"X-Webhook-Secret": "wrong-secret"},
)
assert response.status_code == 401
def test_invalid_payload_returns_422(self) -> None:
app = _make_test_app()
with TestClient(app) as client:
response = client.post(
"/webhooks/azdo",
json={"invalid": "payload"},
headers={"X-Webhook-Secret": VALID_SECRET},
)
assert response.status_code == 422
def test_active_pr_event_returns_200_ignored(self) -> None:
app = _make_test_app()
with TestClient(app) as client:
response = client.post(
"/webhooks/azdo",
json=_ACTIVE_PR_PAYLOAD,
headers={"X-Webhook-Secret": VALID_SECRET},
)
assert response.status_code == 200
data = response.json()
assert "ignored" in data.get("message", "").lower() or "ignored" in str(data).lower()
def test_completed_pr_thread_id_is_string(self) -> None:
app = _make_test_app()
with patch(
"release_agent.api.webhooks.asyncio.create_task", return_value=MagicMock()
):
with TestClient(app) as client:
response = client.post(
"/webhooks/azdo",
json=_COMPLETED_PR_PAYLOAD,
headers={"X-Webhook-Secret": VALID_SECRET},
)
assert response.status_code == 202
assert isinstance(response.json()["thread_id"], str)
def test_completed_pr_schedules_background_task(self) -> None:
app = _make_test_app()
task_mock = MagicMock()
with patch(
"release_agent.api.webhooks.asyncio.create_task", return_value=task_mock
) as mock_create:
with TestClient(app) as client:
client.post(
"/webhooks/azdo",
json=_COMPLETED_PR_PAYLOAD,
headers={"X-Webhook-Secret": VALID_SECRET},
)
mock_create.assert_called_once()
# ---------------------------------------------------------------------------
# Error response shape
# ---------------------------------------------------------------------------
class TestWebhookErrorShape:
def test_401_has_detail_field(self) -> None:
app = _make_test_app()
with TestClient(app) as client:
response = client.post(
"/webhooks/azdo",
json=_COMPLETED_PR_PAYLOAD,
)
assert response.status_code == 401
# FastAPI HTTPException returns {"detail": ...}
assert "detail" in response.json()