Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions openhands/automation/event_schemas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
38 changes: 38 additions & 0 deletions openhands/automation/event_schemas/bitbucket_data_center.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 1 addition & 1 deletion openhands/automation/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions openhands/automation/utils/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
71 changes: 71 additions & 0 deletions tests/test_event_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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,
Expand Down
56 changes: 56 additions & 0 deletions tests/test_event_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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."""

Expand Down
8 changes: 7 additions & 1 deletion tests/test_webhook_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
Loading