From cb8a5fd74fd1e9c6874c6ad08d9c3ddedbe25938 Mon Sep 17 00:00:00 2001 From: Alona King Date: Fri, 22 May 2026 08:46:21 -0400 Subject: [PATCH 1/2] feat: add Jira Data Center automation events --- .../automation/event_schemas/__init__.py | 2 + openhands/automation/event_schemas/jira_dc.py | 35 ++++++++++ openhands/automation/schemas.py | 4 +- openhands/automation/utils/webhook.py | 1 + tests/test_event_router.py | 67 +++++++++++++++++++ tests/test_event_schemas.py | 52 ++++++++++++++ tests/test_webhook_router.py | 12 +++- 7 files changed, 168 insertions(+), 5 deletions(-) create mode 100644 openhands/automation/event_schemas/jira_dc.py diff --git a/openhands/automation/event_schemas/__init__.py b/openhands/automation/event_schemas/__init__.py index 18b659b..2521c2b 100644 --- a/openhands/automation/event_schemas/__init__.py +++ b/openhands/automation/event_schemas/__init__.py @@ -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() diff --git a/openhands/automation/event_schemas/jira_dc.py b/openhands/automation/event_schemas/jira_dc.py new file mode 100644 index 0000000..e07cd24 --- /dev/null +++ b/openhands/automation/event_schemas/jira_dc.py @@ -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) diff --git a/openhands/automation/schemas.py b/openhands/automation/schemas.py index aee142b..5a90132 100644 --- a/openhands/automation/schemas.py +++ b/openhands/automation/schemas.py @@ -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 @@ -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 diff --git a/openhands/automation/utils/webhook.py b/openhands/automation/utils/webhook.py index 6a62b0f..ff860ac 100644 --- a/openhands/automation/utils/webhook.py +++ b/openhands/automation/utils/webhook.py @@ -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, } diff --git a/tests/test_event_router.py b/tests/test_event_router.py index d6e4d8c..0c77414 100644 --- a/tests/test_event_router.py +++ b/tests/test_event_router.py @@ -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. @@ -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, diff --git a/tests/test_event_schemas.py b/tests/test_event_schemas.py index a5e90cb..fd0821a 100644 --- a/tests/test_event_schemas.py +++ b/tests/test_event_schemas.py @@ -605,6 +605,58 @@ 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 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.""" diff --git a/tests/test_webhook_router.py b/tests/test_webhook_router.py index 98bc33e..a339694 100644 --- a/tests/test_webhook_router.py +++ b/tests/test_webhook_router.py @@ -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: @@ -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: From 24c8074602c85097c2d4e0471845bfa28449ec41 Mon Sep 17 00:00:00 2001 From: Alona King Date: Fri, 22 May 2026 08:55:26 -0400 Subject: [PATCH 2/2] test: type narrow Jira DC automation event --- tests/test_event_schemas.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_event_schemas.py b/tests/test_event_schemas.py index fd0821a..f719ba1 100644 --- a/tests/test_event_schemas.py +++ b/tests/test_event_schemas.py @@ -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 @@ -618,6 +619,7 @@ def test_parse_jira_dc_comment_created(self): 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