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
2 changes: 2 additions & 0 deletions openhands/automation/event_schemas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,10 @@ def parse_event(
def _register_builtin_parsers() -> None:
"""Register parsers for built-in sources. Called at module load."""
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("github", parse_github_event_auto)
register_parser("jira_dc", parse_jira_dc_event)


_register_builtin_parsers()
35 changes: 35 additions & 0 deletions openhands/automation/event_schemas/jira_dc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Jira Data Center webhook event parsing."""

from typing import Any, ClassVar

from pydantic import Field, computed_field

from openhands.automation.event_schemas import WebhookEvent


class JiraDcEvent(WebhookEvent):
"""Jira Data Center webhook event.

Jira DC exposes the event identity in the top-level ``webhookEvent`` field.
The raw payload is preserved for automation code and filtering.
"""

_source: ClassVar[str] = "jira_dc"

webhook_event: str = Field(alias="webhookEvent")
payload: dict[str, Any]

@computed_field
@property
def event_key(self) -> str:
"""Return the Jira DC webhook event key."""
return self.webhook_event


def parse_jira_dc_event(payload: dict[str, Any]) -> JiraDcEvent:
"""Parse a Jira DC webhook payload."""
event_key = payload.get("webhookEvent")
if not isinstance(event_key, str) or not event_key:
raise ValueError("Cannot detect jira_dc event type")

return JiraDcEvent(webhookEvent=event_key, payload=payload)
4 changes: 2 additions & 2 deletions openhands/automation/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ class WebhookConfig(BaseModel):
model_config = ConfigDict(extra="forbid")

secret: str
is_builtin: bool = False # True for github
is_builtin: bool = False # True for built-in OpenHands-forwarded sources
event_key_expr: str = "type" # JMESPath expression for extracting event key
signature_header: str = "X-Hub-Signature-256" # HTTP header for signature

Expand All @@ -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"})
RESERVED_SOURCES = frozenset({"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 @@ -34,6 +34,7 @@

BUILTIN_SOURCES: dict[str, BuiltinConfigFunc] = {
"github": lambda s: s.webhook_secret or None,
"jira_dc": lambda s: s.webhook_secret or None,
}


Expand Down
67 changes: 67 additions & 0 deletions tests/test_event_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,26 @@ def github_pr_payload() -> dict:
}


@pytest.fixture
def jira_dc_comment_payload() -> dict:
"""Sample OpenHands-forwarded Jira DC comment event payload."""
return {
"organization": {
"jira_dc_workspace": "jira.company.com",
"openhands_org_id": "00000000-0000-0000-0000-000000000123",
},
"payload": {
"webhookEvent": "comment_created",
"comment": {"body": "please review @openhands"},
"issue": {
"id": "12345",
"key": "PROJ-123",
"self": "https://jira.company.com/rest/api/2/issue/12345",
},
},
}


def sign_payload(payload: dict, secret: str) -> tuple[str, bytes]:
"""Generate HMAC signature for payload.

Expand Down Expand Up @@ -170,6 +190,53 @@ async def test_receive_github_event_with_matching_automation(
assert len(data["runs_created"]) == 1


@pytest.mark.asyncio
async def test_receive_jira_dc_event_with_matching_automation(
async_client: AsyncClient,
org_id: uuid.UUID,
jira_dc_comment_payload: dict,
async_session,
monkeypatch: pytest.MonkeyPatch,
mock_authenticated_user,
):
"""Test receiving Jira DC 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 Jira DC Automation",
tarball_path="oh-internal://uploads/test.tar.gz",
entrypoint="python main.py",
trigger={
"type": "event",
"source": "jira_dc",
"on": "comment_created",
"filter": "icontains(comment.body, '@openhands')",
},
)
async_session.add(automation)
await async_session.commit()

signature, body = sign_payload(jira_dc_comment_payload, "test-secret")

response = await async_client.post(
f"/api/automation/v1/events/{org_id}/jira_dc",
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
54 changes: 54 additions & 0 deletions tests/test_event_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
detect_github_event_type,
parse_github_event_auto,
)
from openhands.automation.event_schemas.jira_dc import JiraDcEvent
from openhands.automation.schemas import EventTrigger
from openhands.automation.trigger_matcher import matches_trigger

Expand Down Expand Up @@ -605,6 +606,59 @@ def test_custom_webhook_with_filter(self):
assert result is False


class TestJiraDcEvent:
"""Tests for Jira Data Center webhook events."""

def test_parse_jira_dc_comment_created(self):
"""Jira DC event keys come from webhookEvent."""
payload = {
"webhookEvent": "comment_created",
"comment": {"body": "hello"},
"issue": {"key": "PROJ-123"},
}

event = parse_event("jira_dc", payload)

assert isinstance(event, JiraDcEvent)
assert event.source == "jira_dc"
assert event.event_key == "comment_created"
assert event.payload == payload

def test_parse_jira_dc_issue_updated(self):
"""Jira DC issue update events are exposed as their webhookEvent."""
payload = {
"webhookEvent": "jira:issue_updated",
"changelog": {"items": []},
"issue": {"key": "PROJ-123"},
}

event = parse_event("jira_dc", payload)

assert event.source == "jira_dc"
assert event.event_key == "jira:issue_updated"

def test_jira_dc_trigger_matching_with_filter(self):
"""Jira DC events support normal trigger filters."""
payload = {
"webhookEvent": "comment_created",
"comment": {"body": "please review @openhands"},
"issue": {"key": "PROJ-123"},
}

trigger = EventTrigger(
source="jira_dc",
on="comment_created",
filter="icontains(comment.body, '@openhands')",
)

assert matches_trigger(trigger, "jira_dc", "comment_created", payload) is True

def test_jira_dc_missing_webhook_event(self):
"""Jira DC events require webhookEvent."""
with pytest.raises(ValueError, match="Cannot detect jira_dc event type"):
parse_event("jira_dc", {"issue": {"key": "PROJ-123"}})


class TestMalformedPayloads:
"""Tests for handling malformed payloads."""

Expand Down
12 changes: 9 additions & 3 deletions tests/test_webhook_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ def test_reserved_source_github_rejected(self):
CustomWebhookCreate(name="Test", source="github")
assert "reserved source name" in str(exc_info.value)

def test_reserved_source_jira_dc_rejected(self):
"""Reserved source 'jira_dc' should be rejected."""
with pytest.raises(ValidationError) as exc_info:
CustomWebhookCreate(name="Test", source="jira_dc")
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 @@ -198,9 +204,9 @@ def test_github_is_reserved(self):
"""GitHub should be reserved."""
assert "github" in RESERVED_SOURCES

def test_only_github_reserved(self):
"""Only GitHub should be reserved (for now)."""
assert RESERVED_SOURCES == {"github"}
def test_builtin_sources_reserved(self):
"""Built-in sources should be reserved."""
assert RESERVED_SOURCES == {"github", "jira_dc"}


class TestWebhookSecretGeneration:
Expand Down
Loading