diff --git a/openhands/automation/event_schemas/__init__.py b/openhands/automation/event_schemas/__init__.py index 2521c2b..8c392f3 100644 --- a/openhands/automation/event_schemas/__init__.py +++ b/openhands/automation/event_schemas/__init__.py @@ -118,9 +118,13 @@ def parse_event( def _register_builtin_parsers() -> None: """Register parsers for built-in sources. Called at module load.""" + from openhands.automation.event_schemas.bitbucket_data_center import ( + parse_bitbucket_data_center_event, + ) from openhands.automation.event_schemas.github import parse_github_event_auto from openhands.automation.event_schemas.jira_dc import parse_jira_dc_event + register_parser("bitbucket_data_center", parse_bitbucket_data_center_event) register_parser("github", parse_github_event_auto) register_parser("jira_dc", parse_jira_dc_event) diff --git a/openhands/automation/event_schemas/bitbucket_data_center.py b/openhands/automation/event_schemas/bitbucket_data_center.py new file mode 100644 index 0000000..299ed47 --- /dev/null +++ b/openhands/automation/event_schemas/bitbucket_data_center.py @@ -0,0 +1,38 @@ +"""Bitbucket Data Center webhook event parsing.""" + +from typing import Any, ClassVar + +from pydantic import Field, computed_field + +from openhands.automation.event_schemas import WebhookEvent + + +class BitbucketDataCenterEvent(WebhookEvent): + """Bitbucket Data Center webhook event. + + Bitbucket Data Center exposes the event identity in the top-level + ``eventKey`` field. The raw payload is preserved for automation code and + filtering. + """ + + _source: ClassVar[str] = "bitbucket_data_center" + + event_key_value: str = Field(alias="eventKey") + payload: dict[str, Any] + + @computed_field + @property + def event_key(self) -> str: + """Return the Bitbucket Data Center webhook event key.""" + return self.event_key_value + + +def parse_bitbucket_data_center_event( + payload: dict[str, Any], +) -> BitbucketDataCenterEvent: + """Parse a Bitbucket Data Center webhook payload.""" + event_key = payload.get("eventKey") + if not isinstance(event_key, str) or not event_key: + raise ValueError("Cannot detect bitbucket_data_center event type") + + return BitbucketDataCenterEvent(eventKey=event_key, payload=payload) diff --git a/openhands/automation/schemas.py b/openhands/automation/schemas.py index 5a90132..3667fe2 100644 --- a/openhands/automation/schemas.py +++ b/openhands/automation/schemas.py @@ -387,7 +387,7 @@ class EventResponse(BaseModel): _SOURCE_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,48}[a-z0-9]$|^[a-z0-9]$") # Reserved source names (built-in integrations) -RESERVED_SOURCES = frozenset({"github", "jira_dc"}) +RESERVED_SOURCES = frozenset({"bitbucket_data_center", "github", "jira_dc"}) # Valid HTTP header name pattern diff --git a/openhands/automation/utils/webhook.py b/openhands/automation/utils/webhook.py index ff860ac..39c4d5f 100644 --- a/openhands/automation/utils/webhook.py +++ b/openhands/automation/utils/webhook.py @@ -33,6 +33,7 @@ BuiltinConfigFunc = Callable[[Settings], str | None] BUILTIN_SOURCES: dict[str, BuiltinConfigFunc] = { + "bitbucket_data_center": lambda s: s.webhook_secret or None, "github": lambda s: s.webhook_secret or None, "jira_dc": lambda s: s.webhook_secret or None, } diff --git a/tests/test_event_router.py b/tests/test_event_router.py index 0c77414..63ef4dc 100644 --- a/tests/test_event_router.py +++ b/tests/test_event_router.py @@ -103,6 +103,30 @@ def jira_dc_comment_payload() -> dict: } +@pytest.fixture +def bitbucket_data_center_pr_payload() -> dict: + """Sample OpenHands-forwarded Bitbucket Data Center PR event payload.""" + return { + "organization": { + "git_org": "PROJ", + "openhands_org_id": "00000000-0000-0000-0000-000000000123", + }, + "payload": { + "eventKey": "pr:opened", + "pullRequest": { + "id": 1, + "title": "Test PR", + "toRef": { + "repository": { + "slug": "myrepo", + "project": {"key": "PROJ"}, + } + }, + }, + }, + } + + def sign_payload(payload: dict, secret: str) -> tuple[str, bytes]: """Generate HMAC signature for payload. @@ -237,6 +261,53 @@ async def test_receive_jira_dc_event_with_matching_automation( assert len(data["runs_created"]) == 1 +@pytest.mark.asyncio +async def test_receive_bitbucket_data_center_event_with_matching_automation( + async_client: AsyncClient, + org_id: uuid.UUID, + bitbucket_data_center_pr_payload: dict, + async_session, + monkeypatch: pytest.MonkeyPatch, + mock_authenticated_user, +): + """Test receiving Bitbucket Data Center event that matches an automation.""" + monkeypatch.setenv("AUTOMATION_WEBHOOK_SECRET", "test-secret") + + automation = Automation( + id=uuid.uuid4(), + user_id=mock_authenticated_user.user_id, + org_id=org_id, + name="Test Bitbucket DC Automation", + tarball_path="oh-internal://uploads/test.tar.gz", + entrypoint="python main.py", + trigger={ + "type": "event", + "source": "bitbucket_data_center", + "on": "pr:opened", + "filter": "pullRequest.toRef.repository.project.key == 'PROJ'", + }, + ) + async_session.add(automation) + await async_session.commit() + + signature, body = sign_payload(bitbucket_data_center_pr_payload, "test-secret") + + response = await async_client.post( + f"/api/automation/v1/events/{org_id}/bitbucket_data_center", + content=body, + headers={ + "X-Hub-Signature-256": signature, + "Content-Type": "application/json", + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["received"] is True + assert data["matched"] == 1 + assert len(data["runs_created"]) == 1 + + @pytest.mark.asyncio async def test_receive_github_event_invalid_signature( async_client: AsyncClient, diff --git a/tests/test_event_schemas.py b/tests/test_event_schemas.py index f719ba1..4f0939d 100644 --- a/tests/test_event_schemas.py +++ b/tests/test_event_schemas.py @@ -5,6 +5,9 @@ from openhands.automation.event_schemas import ( parse_event, ) +from openhands.automation.event_schemas.bitbucket_data_center import ( + BitbucketDataCenterEvent, +) from openhands.automation.event_schemas.detection import EventTypeDetector from openhands.automation.event_schemas.github import ( GITHUB_DETECTION_RULES, @@ -659,6 +662,59 @@ def test_jira_dc_missing_webhook_event(self): parse_event("jira_dc", {"issue": {"key": "PROJ-123"}}) +class TestBitbucketDataCenterEvent: + """Tests for Bitbucket Data Center webhook events.""" + + def test_parse_bitbucket_data_center_pr_opened(self): + """Bitbucket Data Center event keys come from eventKey.""" + payload = { + "eventKey": "pr:opened", + "pullRequest": {"id": 1, "title": "Test PR"}, + "actor": {"name": "alona"}, + } + + event = parse_event("bitbucket_data_center", payload) + + assert isinstance(event, BitbucketDataCenterEvent) + assert event.source == "bitbucket_data_center" + assert event.event_key == "pr:opened" + assert event.payload == payload + + def test_bitbucket_data_center_trigger_matching_with_filter(self): + """Bitbucket Data Center events support normal trigger filters.""" + payload = { + "eventKey": "repo:refs_changed", + "repository": { + "slug": "myrepo", + "project": {"key": "PROJ"}, + }, + "changes": [{"refId": "refs/heads/main"}], + } + + trigger = EventTrigger( + source="bitbucket_data_center", + on="repo:refs_changed", + filter="repository.project.key == 'PROJ'", + ) + + assert ( + matches_trigger( + trigger, + "bitbucket_data_center", + "repo:refs_changed", + payload, + ) + is True + ) + + def test_bitbucket_data_center_missing_event_key(self): + """Bitbucket Data Center events require eventKey.""" + with pytest.raises( + ValueError, match="Cannot detect bitbucket_data_center event type" + ): + parse_event("bitbucket_data_center", {"repository": {"slug": "repo"}}) + + class TestMalformedPayloads: """Tests for handling malformed payloads.""" diff --git a/tests/test_webhook_router.py b/tests/test_webhook_router.py index a339694..908346e 100644 --- a/tests/test_webhook_router.py +++ b/tests/test_webhook_router.py @@ -60,6 +60,12 @@ def test_reserved_source_jira_dc_rejected(self): CustomWebhookCreate(name="Test", source="jira_dc") assert "reserved source name" in str(exc_info.value) + def test_reserved_source_bitbucket_data_center_rejected(self): + """Reserved source 'bitbucket_data_center' should be rejected.""" + with pytest.raises(ValidationError) as exc_info: + CustomWebhookCreate(name="Test", source="bitbucket_data_center") + assert "reserved source name" in str(exc_info.value) + def test_reserved_source_case_insensitive(self): """Reserved source check is case-insensitive.""" with pytest.raises(ValidationError) as exc_info: @@ -206,7 +212,7 @@ def test_github_is_reserved(self): def test_builtin_sources_reserved(self): """Built-in sources should be reserved.""" - assert RESERVED_SOURCES == {"github", "jira_dc"} + assert RESERVED_SOURCES == {"bitbucket_data_center", "github", "jira_dc"} class TestWebhookSecretGeneration: