From 275d16f93a27e33dffabd82419fd060b56218f27 Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 24 Apr 2026 20:37:41 +0000 Subject: [PATCH 1/4] feat: Add GitLab event support for automation triggers Add GitLab webhook event handling to the automation service, mirroring the existing GitHub support. This enables event-triggered automations for GitLab repositories. Changes: - Add GitLab event schemas (merge_request, push, tag_push, issue, note, pipeline) - Register GitLab parser in event_schemas/__init__.py - Add 'gitlab' to builtin webhook sources in utils/webhook.py - Add 'gitlab' to reserved sources in schemas.py - Add comprehensive tests for GitLab event parsing, detection, and trigger matching GitLab events are forwarded from the OpenHands server using the same AUTOMATION_WEBHOOK_SECRET and X-Hub-Signature-256 header pattern as GitHub. Event type mapping (GitLab -> GitHub equivalent): - merge_request -> pull_request - push -> push - tag_push -> (tag portion of push) - issue -> issues - note -> issue_comment - pipeline -> (no direct equivalent) Co-authored-by: openhands --- automation/event_schemas/__init__.py | 2 + automation/event_schemas/gitlab.py | 508 +++++++++++++++++++++++++++ automation/schemas.py | 2 +- automation/utils/webhook.py | 1 + tests/test_event_router.py | 291 +++++++++++++++ tests/test_event_schemas.py | 462 ++++++++++++++++++++++++ 6 files changed, 1265 insertions(+), 1 deletion(-) create mode 100644 automation/event_schemas/gitlab.py diff --git a/automation/event_schemas/__init__.py b/automation/event_schemas/__init__.py index 179875c..1520870 100644 --- a/automation/event_schemas/__init__.py +++ b/automation/event_schemas/__init__.py @@ -116,8 +116,10 @@ def parse_event( def _register_builtin_parsers() -> None: """Register parsers for built-in sources. Called at module load.""" from automation.event_schemas.github import parse_github_event_auto + from automation.event_schemas.gitlab import parse_gitlab_event_auto register_parser("github", parse_github_event_auto) + register_parser("gitlab", parse_gitlab_event_auto) _register_builtin_parsers() diff --git a/automation/event_schemas/gitlab.py b/automation/event_schemas/gitlab.py new file mode 100644 index 0000000..50790a7 --- /dev/null +++ b/automation/event_schemas/gitlab.py @@ -0,0 +1,508 @@ +""" +GitLab event schema registry. + +Pydantic models for GitLab webhook events. Each payload class: +1. Validates the payload structure via Pydantic +2. Identifies itself via `event_key` property + +Reference: https://docs.gitlab.com/ee/user/project/integrations/webhooks.html + +Design Decision - extra="ignore": + We use extra="ignore" on all nested models because GitLab's webhook payloads + frequently change (adding new fields). Using extra="forbid" would break on + every GitLab API update. The trade-off is: + - Typos in field names won't error (mitigated by Pydantic's required fields) + - New GitLab fields are silently ignored (acceptable - we only parse what we need) + For critical fields we rely on, Pydantic's required field validation catches + missing data. + +Filtering is handled by the trigger_matcher module using JMESPath expressions +evaluated against the raw payload. Example filters: + - project.path_with_namespace == 'org/repo' + - glob(project.path_with_namespace, 'org/*') + - icontains(object_attributes.description, '@openhands') + - contains(labels[].title, 'bug') +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, ClassVar + +from pydantic import BaseModel, computed_field + +from automation.event_schemas import WebhookEvent + + +if TYPE_CHECKING: + from automation.event_schemas import detection + + +# ============================================================================= +# Shared Payload Models (reused across events) +# ============================================================================= + + +class GitLabUser(BaseModel): + """GitLab user (author, assignee, etc.).""" + + id: int + username: str + name: str = "" + + model_config = {"extra": "ignore"} + + +class GitLabProject(BaseModel): + """GitLab project (repository).""" + + id: int + name: str + path_with_namespace: str + default_branch: str = "main" + visibility_level: int = 0 # 0=private, 10=internal, 20=public + + model_config = {"extra": "ignore"} + + +class GitLabLabel(BaseModel): + """GitLab issue/MR label.""" + + id: int + title: str + color: str = "" + + model_config = {"extra": "ignore"} + + +# ============================================================================= +# Base Class for GitLab Events +# ============================================================================= + + +class GitLabEvent(WebhookEvent): + """ + Base class for all GitLab event payloads. + + Extends WebhookEvent with GitLab-specific fields common to all events. + + Filtering is handled by the trigger_matcher module using JMESPath + expressions evaluated against the raw webhook payload. + """ + + _source: ClassVar[str] = "gitlab" + _event_type: ClassVar[str] + + # All GitLab events have project and user + project: GitLabProject + user: GitLabUser + + @computed_field + @property + def event_key(self) -> str: + """ + Unique identifier for this event instance. + + Format: "{event_type}.{action}" or "{event_type}" if no action. + Examples: "merge_request.open", "push", "issue.close" + """ + action = getattr(self, "action", None) + if action: + return f"{self._event_type}.{action}" + return self._event_type + + +# ============================================================================= +# Merge Request Events (GitLab equivalent of Pull Request) +# ============================================================================= + + +class MergeRequestAttributes(BaseModel): + """Merge request object_attributes.""" + + id: int + iid: int # Internal ID (project-scoped) + title: str + state: str # "opened", "closed", "merged" + draft: bool = False + source_branch: str + target_branch: str + action: str | None = None # "open", "close", "merge", "update", etc. + + model_config = {"extra": "ignore"} + + +class MergeRequestPayload(GitLabEvent): + """ + GitLab merge_request event. + + Triggered on MR activity: open, close, merge, update, etc. + + Event keys: + - merge_request.open + - merge_request.close + - merge_request.merge + - merge_request.update + - merge_request.approved + - merge_request.unapproved + + Common JMESPath filters: + - object_attributes.target_branch == 'main' + - glob(project.path_with_namespace, 'org/*') + - contains(labels[].title, 'bug') + """ + + _event_type: ClassVar[str] = "merge_request" + + object_kind: str = "merge_request" + object_attributes: MergeRequestAttributes + labels: list[GitLabLabel] = [] # noqa: RUF012 + + @computed_field + @property + def action(self) -> str | None: + """Extract action from object_attributes.""" + return self.object_attributes.action + + +# ============================================================================= +# Push Events +# ============================================================================= + + +class PushCommit(BaseModel): + """A commit in a push event.""" + + id: str + message: str + author: dict[str, Any] # {name, email} + + model_config = {"extra": "ignore"} + + +class PushPayload(GitLabEvent): + """ + GitLab push event. + + Triggered when commits are pushed to a repository. + + Event key: "push" (no action field) + + Common JMESPath filters: + - ref == 'refs/heads/main' + - glob(ref, 'refs/heads/release/*') + - starts_with(ref, 'refs/tags/') + """ + + _event_type: ClassVar[str] = "push" + + object_kind: str = "push" + ref: str # refs/heads/main + before: str # SHA before push + after: str # SHA after push + commits: list[PushCommit] = [] # noqa: RUF012 + + @property + def branch(self) -> str: + """Extract branch name from ref.""" + return self.ref.removeprefix("refs/heads/") + + @property + def is_default_branch(self) -> bool: + """Check if push is to default branch.""" + return self.branch == self.project.default_branch + + +# ============================================================================= +# Tag Push Events +# ============================================================================= + + +class TagPushPayload(GitLabEvent): + """ + GitLab tag_push event. + + Triggered when a tag is created or deleted. + + Event key: "tag_push" (no action field) + + Common JMESPath filters: + - glob(ref, 'refs/tags/v*') + """ + + _event_type: ClassVar[str] = "tag_push" + + object_kind: str = "tag_push" + ref: str # refs/tags/v1.0.0 + before: str # SHA before (0000... for create) + after: str # SHA after (0000... for delete) + + @property + def tag_name(self) -> str: + """Extract tag name from ref.""" + return self.ref.removeprefix("refs/tags/") + + @property + def is_create(self) -> bool: + """Check if this is a tag creation (vs deletion).""" + return self.before == "0" * 40 + + +# ============================================================================= +# Issue Events +# ============================================================================= + + +class IssueAttributes(BaseModel): + """Issue object_attributes.""" + + id: int + iid: int # Internal ID (project-scoped) + title: str + state: str # "opened", "closed" + action: str | None = None # "open", "close", "reopen", "update" + + model_config = {"extra": "ignore"} + + +class IssuePayload(GitLabEvent): + """ + GitLab issue event. + + Triggered on issue activity: open, close, reopen, update. + + Event keys: + - issue.open + - issue.close + - issue.reopen + - issue.update + """ + + _event_type: ClassVar[str] = "issue" + + object_kind: str = "issue" + object_attributes: IssueAttributes + labels: list[GitLabLabel] = [] # noqa: RUF012 + + @computed_field + @property + def action(self) -> str | None: + """Extract action from object_attributes.""" + return self.object_attributes.action + + +# ============================================================================= +# Note (Comment) Events +# ============================================================================= + + +class NoteAttributes(BaseModel): + """Note (comment) object_attributes.""" + + id: int + note: str # Comment body + noteable_type: str # "Issue", "MergeRequest", "Snippet", "Commit" + action: str | None = None # Usually None for notes + + model_config = {"extra": "ignore"} + + +class NotePayload(GitLabEvent): + """ + GitLab note event. + + Triggered when a comment is created on an issue, MR, snippet, or commit. + This is GitLab's equivalent of GitHub's issue_comment and + pull_request_review_comment events combined. + + Event key: "note" (no action - comments don't have actions in GitLab) + + Common JMESPath filters: + - icontains(object_attributes.note, '@openhands') + - object_attributes.noteable_type == 'MergeRequest' + - glob(project.path_with_namespace, 'org/*') + """ + + _event_type: ClassVar[str] = "note" + + object_kind: str = "note" + object_attributes: NoteAttributes + # Context objects - only one is present depending on noteable_type + merge_request: dict[str, Any] | None = None + issue: dict[str, Any] | None = None + commit: dict[str, Any] | None = None + snippet: dict[str, Any] | None = None + + +# ============================================================================= +# Pipeline Events +# ============================================================================= + + +class PipelineAttributes(BaseModel): + """Pipeline object_attributes.""" + + id: int + status: str # "pending", "running", "success", "failed", "canceled" + ref: str # Branch or tag name + source: str # "push", "web", "trigger", "schedule", etc. + + model_config = {"extra": "ignore"} + + +class PipelinePayload(GitLabEvent): + """ + GitLab pipeline event. + + Triggered on CI/CD pipeline status changes. + + Event keys (uses status as action): + - pipeline.pending + - pipeline.running + - pipeline.success + - pipeline.failed + - pipeline.canceled + + Common JMESPath filters: + - object_attributes.status == 'failed' + - object_attributes.ref == 'main' + - object_attributes.source == 'push' + """ + + _event_type: ClassVar[str] = "pipeline" + + object_kind: str = "pipeline" + object_attributes: PipelineAttributes + + @computed_field + @property + def action(self) -> str: + """Use pipeline status as action.""" + return self.object_attributes.status + + +# ============================================================================= +# Event Registry +# ============================================================================= + + +# Maps object_kind -> payload class +GITLAB_PAYLOAD_CLASSES: dict[str, type[GitLabEvent]] = { + "merge_request": MergeRequestPayload, + "push": PushPayload, + "tag_push": TagPushPayload, + "issue": IssuePayload, + "note": NotePayload, + "pipeline": PipelinePayload, +} + + +# ============================================================================= +# Event Type Detection +# ============================================================================= + +# Detection rules: (event_type, jmespath_expression) +# GitLab events have an `object_kind` field that identifies the event type +GITLAB_DETECTION_RULES: list[tuple[str, str]] = [ + # GitLab payloads have explicit object_kind field + ("merge_request", "object_kind == 'merge_request'"), + ("push", "object_kind == 'push'"), + ("tag_push", "object_kind == 'tag_push'"), + ("issue", "object_kind == 'issue'"), + ("note", "object_kind == 'note'"), + ("pipeline", "object_kind == 'pipeline'"), + # Fallback detection using payload structure (when object_kind is absent) + ( + "merge_request", + "contains(keys(@), 'object_attributes') && contains(keys(@), 'merge_request')", + ), + ( + "issue", + "contains(keys(@), 'object_attributes') && " + "object_attributes.noteable_type == null && " + "!contains(keys(@), 'merge_request')", + ), +] + +# Lazy-initialized detector (created on first use) +_detector: detection.EventTypeDetector | None = None + + +def _get_detector() -> detection.EventTypeDetector: + """Get or create the GitLab event type detector.""" + from automation.event_schemas import detection + + global _detector + if _detector is None: + _detector = detection.EventTypeDetector(GITLAB_DETECTION_RULES, source="gitlab") + return _detector + + +def detect_gitlab_event_type(payload: dict[str, Any]) -> str: + """ + Detect GitLab event type from payload structure. + + GitLab payloads include an `object_kind` field that identifies the event type, + making detection straightforward. + + Args: + payload: The raw GitLab webhook payload + + Returns: + The event type string (e.g., 'merge_request', 'push') + + Raises: + ValueError: If event type cannot be determined from payload + """ + return _get_detector().detect(payload) + + +# ============================================================================= +# Parsing Functions +# ============================================================================= + + +def parse_gitlab_event(event_type: str, payload: dict[str, Any]) -> GitLabEvent: + """ + Parse a raw GitLab webhook payload into a typed event object. + + Args: + event_type: The event type (from object_kind or detection) + payload: The raw webhook payload + + Returns: + A typed GitLabEvent subclass instance + + Raises: + ValueError: If event_type is unknown + ValidationError: If payload doesn't match expected structure + """ + cls = GITLAB_PAYLOAD_CLASSES.get(event_type) + if cls is None: + raise ValueError(f"Unknown GitLab event type: {event_type}") + return cls.model_validate(payload) + + +def parse_gitlab_event_auto(payload: dict[str, Any]) -> GitLabEvent: + """ + Parse a raw GitLab webhook payload by auto-detecting the event type. + + This is the preferred method when the event type is not provided + (e.g., when forwarded from another service without the header). + + Args: + payload: The raw GitLab webhook payload + + Returns: + A typed GitLabEvent subclass instance + + Raises: + ValueError: If event type cannot be detected or is unsupported + ValidationError: If payload doesn't match expected structure + """ + event_type = detect_gitlab_event_type(payload) + return parse_gitlab_event(event_type, payload) + + +def get_supported_event_types() -> list[str]: + """Get list of all supported GitLab event types.""" + return list(GITLAB_PAYLOAD_CLASSES.keys()) diff --git a/automation/schemas.py b/automation/schemas.py index 4f7cf3b..b4bee23 100644 --- a/automation/schemas.py +++ b/automation/schemas.py @@ -365,7 +365,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", "gitlab"}) # Valid HTTP header name pattern diff --git a/automation/utils/webhook.py b/automation/utils/webhook.py index 4a960bb..522f673 100644 --- a/automation/utils/webhook.py +++ b/automation/utils/webhook.py @@ -33,6 +33,7 @@ BUILTIN_SOURCES: dict[str, BuiltinConfigFunc] = { "github": lambda s: s.webhook_secret or None, + "gitlab": lambda s: s.webhook_secret or None, } diff --git a/tests/test_event_router.py b/tests/test_event_router.py index cf22dde..3d619f3 100644 --- a/tests/test_event_router.py +++ b/tests/test_event_router.py @@ -398,3 +398,294 @@ async def test_receive_unknown_source( assert response.status_code == 404 assert "Unknown webhook source" in response.json()["detail"] + + + +# ============================================================================= +# GitLab Event Tests +# ============================================================================= + + +@pytest.fixture +def gitlab_push_payload() -> dict: + """Sample GitLab push event payload.""" + return { + "payload": { + "object_kind": "push", + "ref": "refs/heads/main", + "before": "abc123", + "after": "def456", + "commits": [ + { + "id": "def456", + "message": "Test commit", + "author": {"name": "Test", "email": "test@example.com"}, + } + ], + "project": { + "id": 123, + "name": "test-repo", + "path_with_namespace": "org/test-repo", + "default_branch": "main", + }, + "user": {"id": 1, "username": "testuser", "name": "Test User"}, + }, + } + + +@pytest.fixture +def gitlab_mr_payload() -> dict: + """Sample GitLab merge_request event payload.""" + return { + "payload": { + "object_kind": "merge_request", + "object_attributes": { + "id": 1, + "iid": 42, + "title": "Test MR", + "state": "opened", + "draft": False, + "source_branch": "feature/test", + "target_branch": "main", + "action": "open", + }, + "project": { + "id": 123, + "name": "test-repo", + "path_with_namespace": "org/test-repo", + "default_branch": "main", + }, + "user": {"id": 1, "username": "testuser", "name": "Test User"}, + "labels": [], + }, + } + + +@pytest.mark.asyncio +async def test_receive_gitlab_event_no_matching_automations( + async_client: AsyncClient, + org_id: uuid.UUID, + gitlab_push_payload: dict, + monkeypatch: pytest.MonkeyPatch, +): + """Test receiving GitLab event with no matching automations.""" + monkeypatch.setenv("AUTOMATION_WEBHOOK_SECRET", "test-secret") + + signature, body = sign_payload(gitlab_push_payload, "test-secret") + + response = await async_client.post( + f"/api/automation/v1/events/{org_id}/gitlab", + 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"] == 0 + assert data["runs_created"] == [] + + +@pytest.mark.asyncio +async def test_receive_gitlab_event_with_matching_automation( + async_client: AsyncClient, + org_id: uuid.UUID, + gitlab_push_payload: dict, + async_session, + monkeypatch: pytest.MonkeyPatch, + mock_authenticated_user, +): + """Test receiving GitLab event that matches an automation.""" + monkeypatch.setenv("AUTOMATION_WEBHOOK_SECRET", "test-secret") + + # Create an event-triggered automation for GitLab + automation = Automation( + id=uuid.uuid4(), + user_id=mock_authenticated_user.user_id, + org_id=org_id, + name="Test GitLab Push Automation", + tarball_path="oh-internal://uploads/test.tar.gz", + entrypoint="python main.py", + trigger={ + "type": "event", + "source": "gitlab", + "on": "push", # Match push events + }, + ) + async_session.add(automation) + await async_session.commit() + + signature, body = sign_payload(gitlab_push_payload, "test-secret") + + response = await async_client.post( + f"/api/automation/v1/events/{org_id}/gitlab", + 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_gitlab_merge_request_event( + async_client: AsyncClient, + org_id: uuid.UUID, + gitlab_mr_payload: dict, + async_session, + monkeypatch: pytest.MonkeyPatch, + mock_authenticated_user, +): + """Test receiving GitLab merge_request event.""" + monkeypatch.setenv("AUTOMATION_WEBHOOK_SECRET", "test-secret") + + # Create an event-triggered automation for GitLab MRs + automation = Automation( + id=uuid.uuid4(), + user_id=mock_authenticated_user.user_id, + org_id=org_id, + name="Test GitLab MR Automation", + tarball_path="oh-internal://uploads/test.tar.gz", + entrypoint="python main.py", + trigger={ + "type": "event", + "source": "gitlab", + "on": "merge_request.open", + }, + ) + async_session.add(automation) + await async_session.commit() + + signature, body = sign_payload(gitlab_mr_payload, "test-secret") + + response = await async_client.post( + f"/api/automation/v1/events/{org_id}/gitlab", + 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 + + +@pytest.mark.asyncio +async def test_receive_gitlab_event_invalid_signature( + async_client: AsyncClient, + org_id: uuid.UUID, + gitlab_push_payload: dict, + monkeypatch: pytest.MonkeyPatch, +): + """Test that invalid signature is rejected for GitLab events.""" + monkeypatch.setenv("AUTOMATION_WEBHOOK_SECRET", "test-secret") + + _, body = sign_payload(gitlab_push_payload, "test-secret") + + # Wrong signature + response = await async_client.post( + f"/api/automation/v1/events/{org_id}/gitlab", + content=body, + headers={ + "X-Hub-Signature-256": "sha256=invalid", + "Content-Type": "application/json", + }, + ) + + assert response.status_code == 401 + assert "Invalid signature" in response.json()["detail"] + + +@pytest.mark.asyncio +async def test_receive_gitlab_event_filter_mismatch( + async_client: AsyncClient, + org_id: uuid.UUID, + gitlab_push_payload: dict, + async_session, + monkeypatch: pytest.MonkeyPatch, + mock_authenticated_user, +): + """Test that GitLab events not matching filters don't create runs.""" + monkeypatch.setenv("AUTOMATION_WEBHOOK_SECRET", "test-secret") + + # Create automation that filters on different project (using JMESPath filter) + automation = Automation( + id=uuid.uuid4(), + user_id=mock_authenticated_user.user_id, + org_id=org_id, + name="Test GitLab Push Automation", + tarball_path="oh-internal://uploads/test.tar.gz", + entrypoint="python main.py", + trigger={ + "type": "event", + "source": "gitlab", + "on": "push", + "filter": "project.path_with_namespace == 'different/repo'", + }, + ) + async_session.add(automation) + await async_session.commit() + + signature, body = sign_payload(gitlab_push_payload, "test-secret") + + response = await async_client.post( + f"/api/automation/v1/events/{org_id}/gitlab", + 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"] == 0 # No match due to filter + + +@pytest.mark.asyncio +async def test_receive_gitlab_event_unknown_event_type( + async_client: AsyncClient, + org_id: uuid.UUID, + monkeypatch: pytest.MonkeyPatch, +): + """Test that unknown GitLab event type returns 400.""" + monkeypatch.setenv("AUTOMATION_WEBHOOK_SECRET", "test-secret") + + payload = { + "payload": { + "object_kind": "unknown_gitlab_event", + "project": { + "id": 123, + "name": "test-repo", + "path_with_namespace": "org/test-repo", + "default_branch": "main", + }, + "user": {"id": 1, "username": "testuser", "name": "Test User"}, + }, + } + signature, body = sign_payload(payload, "test-secret") + + response = await async_client.post( + f"/api/automation/v1/events/{org_id}/gitlab", + content=body, + headers={ + "X-Hub-Signature-256": signature, + "Content-Type": "application/json", + }, + ) + + assert response.status_code == 400 + assert "Cannot detect gitlab event type" in response.json()["detail"] diff --git a/tests/test_event_schemas.py b/tests/test_event_schemas.py index 666a192..7901f02 100644 --- a/tests/test_event_schemas.py +++ b/tests/test_event_schemas.py @@ -17,6 +17,17 @@ detect_github_event_type, parse_github_event_auto, ) +from automation.event_schemas.gitlab import ( + GITLAB_DETECTION_RULES, + IssuePayload as GitLabIssuePayload, + MergeRequestPayload, + NotePayload, + PipelinePayload, + PushPayload as GitLabPushPayload, + TagPushPayload, + detect_gitlab_event_type, + parse_gitlab_event_auto, +) from automation.schemas import EventTrigger from automation.trigger_matcher import matches_trigger @@ -894,3 +905,454 @@ def test_rules_cover_all_payload_classes(self): assert "issue_comment" in supported assert "push" in supported assert "release" in supported + + + +# ============================================================================= +# GitLab Event Tests +# ============================================================================= + + +class TestGitLabEventParsing: + """Tests for GitLab event parsing with auto-detection.""" + + def _base_payload(self) -> dict: + """Base payload with required GitLab fields.""" + return { + "project": { + "id": 123, + "name": "test-repo", + "path_with_namespace": "org/test-repo", + "default_branch": "main", + "visibility_level": 0, + }, + "user": { + "id": 1, + "username": "testuser", + "name": "Test User", + }, + } + + def test_parse_merge_request_open(self): + """Parse merge_request.open event via auto-detection.""" + payload = { + **self._base_payload(), + "object_kind": "merge_request", + "object_attributes": { + "id": 1, + "iid": 42, + "title": "Test MR", + "state": "opened", + "draft": False, + "source_branch": "feature/test", + "target_branch": "main", + "action": "open", + }, + "labels": [], + } + + event = parse_event("gitlab", payload) + + assert isinstance(event, MergeRequestPayload) + assert event.event_key == "merge_request.open" + assert event.source == "gitlab" + assert event.object_attributes.iid == 42 + + def test_parse_push_event(self): + """Parse push event via auto-detection.""" + payload = { + **self._base_payload(), + "object_kind": "push", + "ref": "refs/heads/main", + "before": "abc123", + "after": "def456", + "commits": [ + { + "id": "def456", + "message": "Test commit", + "author": {"name": "Test", "email": "test@example.com"}, + } + ], + } + + event = parse_event("gitlab", payload) + + assert isinstance(event, GitLabPushPayload) + assert event.event_key == "push" + assert event.ref == "refs/heads/main" + assert event.branch == "main" + + def test_parse_tag_push_event(self): + """Parse tag_push event via auto-detection.""" + payload = { + **self._base_payload(), + "object_kind": "tag_push", + "ref": "refs/tags/v1.0.0", + "before": "0" * 40, + "after": "def456", + } + + event = parse_event("gitlab", payload) + + assert isinstance(event, TagPushPayload) + assert event.event_key == "tag_push" + assert event.tag_name == "v1.0.0" + assert event.is_create is True + + def test_parse_issue_event(self): + """Parse issue.open event via auto-detection.""" + payload = { + **self._base_payload(), + "object_kind": "issue", + "object_attributes": { + "id": 1, + "iid": 10, + "title": "Bug report", + "state": "opened", + "action": "open", + }, + "labels": [], + } + + event = parse_event("gitlab", payload) + + assert isinstance(event, GitLabIssuePayload) + assert event.event_key == "issue.open" + assert event.object_attributes.iid == 10 + + def test_parse_note_event(self): + """Parse note event via auto-detection.""" + payload = { + **self._base_payload(), + "object_kind": "note", + "object_attributes": { + "id": 1, + "note": "Test comment @openhands help", + "noteable_type": "MergeRequest", + }, + "merge_request": { + "id": 42, + "iid": 5, + "title": "Test MR", + }, + } + + event = parse_event("gitlab", payload) + + assert isinstance(event, NotePayload) + assert event.event_key == "note" + assert event.object_attributes.note == "Test comment @openhands help" + assert event.object_attributes.noteable_type == "MergeRequest" + + def test_parse_pipeline_event(self): + """Parse pipeline.success event via auto-detection.""" + payload = { + **self._base_payload(), + "object_kind": "pipeline", + "object_attributes": { + "id": 1, + "status": "success", + "ref": "main", + "source": "push", + }, + } + + event = parse_event("gitlab", payload) + + assert isinstance(event, PipelinePayload) + assert event.event_key == "pipeline.success" + assert event.object_attributes.status == "success" + + def test_parse_unknown_payload_raises(self): + """Unknown payload structure should raise ValueError.""" + payload = { + **self._base_payload(), + "object_kind": "unknown_event", + } + + with pytest.raises(ValueError, match="Cannot detect gitlab event type"): + parse_event("gitlab", payload) + + +class TestGitLabAutoDetection: + """Tests for GitLab event type auto-detection.""" + + def _base_payload(self) -> dict: + """Base payload with required fields.""" + return { + "project": { + "id": 123, + "name": "test-repo", + "path_with_namespace": "org/test-repo", + "default_branch": "main", + }, + "user": {"id": 1, "username": "testuser", "name": "Test User"}, + } + + def test_detect_merge_request(self): + """Detect merge_request event.""" + payload = { + **self._base_payload(), + "object_kind": "merge_request", + "object_attributes": { + "id": 1, + "iid": 42, + "title": "Test MR", + "state": "opened", + "source_branch": "feature", + "target_branch": "main", + "action": "open", + }, + } + + assert detect_gitlab_event_type(payload) == "merge_request" + + event = parse_gitlab_event_auto(payload) + assert isinstance(event, MergeRequestPayload) + assert event.event_key == "merge_request.open" + + def test_detect_push(self): + """Detect push event.""" + payload = { + **self._base_payload(), + "object_kind": "push", + "ref": "refs/heads/main", + "before": "abc123", + "after": "def456", + "commits": [], + } + + assert detect_gitlab_event_type(payload) == "push" + + event = parse_gitlab_event_auto(payload) + assert isinstance(event, GitLabPushPayload) + assert event.event_key == "push" + + def test_detect_tag_push(self): + """Detect tag_push event.""" + payload = { + **self._base_payload(), + "object_kind": "tag_push", + "ref": "refs/tags/v1.0.0", + "before": "0" * 40, + "after": "def456", + } + + assert detect_gitlab_event_type(payload) == "tag_push" + + event = parse_gitlab_event_auto(payload) + assert isinstance(event, TagPushPayload) + assert event.event_key == "tag_push" + + def test_detect_issue(self): + """Detect issue event.""" + payload = { + **self._base_payload(), + "object_kind": "issue", + "object_attributes": { + "id": 1, + "iid": 10, + "title": "Test issue", + "state": "opened", + "action": "open", + }, + } + + assert detect_gitlab_event_type(payload) == "issue" + + event = parse_gitlab_event_auto(payload) + assert isinstance(event, GitLabIssuePayload) + assert event.event_key == "issue.open" + + def test_detect_note(self): + """Detect note event.""" + payload = { + **self._base_payload(), + "object_kind": "note", + "object_attributes": { + "id": 1, + "note": "Test comment", + "noteable_type": "Issue", + }, + } + + assert detect_gitlab_event_type(payload) == "note" + + event = parse_gitlab_event_auto(payload) + assert isinstance(event, NotePayload) + assert event.event_key == "note" + + def test_detect_pipeline(self): + """Detect pipeline event.""" + payload = { + **self._base_payload(), + "object_kind": "pipeline", + "object_attributes": { + "id": 1, + "status": "failed", + "ref": "main", + "source": "push", + }, + } + + assert detect_gitlab_event_type(payload) == "pipeline" + + event = parse_gitlab_event_auto(payload) + assert isinstance(event, PipelinePayload) + assert event.event_key == "pipeline.failed" + + def test_detect_unknown_raises(self): + """Unknown object_kind raises ValueError.""" + payload = { + **self._base_payload(), + "object_kind": "unknown_event", + } + + with pytest.raises(ValueError, match="Cannot detect gitlab event type"): + detect_gitlab_event_type(payload) + + +class TestGitLabDetectionRules: + """Tests for GITLAB_DETECTION_RULES configuration.""" + + def test_rules_are_valid_jmespath(self): + """All detection rules should be valid JMESPath expressions.""" + # This will raise if any expression is invalid + detector = EventTypeDetector(GITLAB_DETECTION_RULES, source="gitlab") + assert len(detector.rules) == len(GITLAB_DETECTION_RULES) + + def test_rules_cover_all_payload_classes(self): + """Detection rules should cover common event types.""" + detector = EventTypeDetector(GITLAB_DETECTION_RULES, source="gitlab") + supported = set(detector.supported_types) + + # Core event types should be detectable + assert "merge_request" in supported + assert "push" in supported + assert "tag_push" in supported + assert "issue" in supported + assert "note" in supported + assert "pipeline" in supported + + +class TestGitLabTriggerMatching: + """Tests for GitLab event trigger matching using JMESPath filters.""" + + def _mr_payload( + self, + action: str = "open", + project: str = "org/test-repo", + target_branch: str = "main", + ) -> dict: + """Create a merge request payload dict.""" + return { + "object_kind": "merge_request", + "object_attributes": { + "id": 1, + "iid": 42, + "title": "Test MR", + "state": "opened", + "draft": False, + "source_branch": "feature/test", + "target_branch": target_branch, + "action": action, + }, + "project": { + "id": 123, + "name": project.split("/")[1], + "path_with_namespace": project, + "default_branch": "main", + }, + "user": {"id": 1, "username": "testuser", "name": "Test User"}, + "labels": [{"id": 1, "title": "bug", "color": "#ff0000"}], + } + + def _note_payload(self, note: str, project: str = "org/test-repo") -> dict: + """Create a note (comment) payload dict.""" + return { + "object_kind": "note", + "object_attributes": { + "id": 1, + "note": note, + "noteable_type": "MergeRequest", + }, + "project": { + "id": 123, + "name": project.split("/")[1], + "path_with_namespace": project, + "default_branch": "main", + }, + "user": {"id": 1, "username": "testuser", "name": "Test User"}, + "merge_request": {"id": 42, "iid": 5, "title": "Test MR"}, + } + + def test_match_mr_open_event(self): + """Basic MR open event matching.""" + payload = self._mr_payload() + trigger = EventTrigger(source="gitlab", on="merge_request.open") + + assert matches_trigger(trigger, "gitlab", "merge_request.open", payload) is True + + def test_match_mr_with_wildcard(self): + """Wildcard matching for any MR action.""" + payload = self._mr_payload(action="merge") + trigger = EventTrigger(source="gitlab", on="merge_request.*") + + assert ( + matches_trigger(trigger, "gitlab", "merge_request.merge", payload) is True + ) + + def test_match_mr_with_project_filter(self): + """Filter MR by project path.""" + payload = self._mr_payload(project="org/test-repo") + trigger = EventTrigger( + source="gitlab", + on="merge_request.open", + filter="project.path_with_namespace == 'org/test-repo'", + ) + + assert matches_trigger(trigger, "gitlab", "merge_request.open", payload) is True + + def test_match_mr_with_glob_filter(self): + """Filter MR by project glob pattern.""" + payload = self._mr_payload(project="org/test-repo") + trigger = EventTrigger( + source="gitlab", + on="merge_request.open", + filter="glob(project.path_with_namespace, 'org/*')", + ) + + assert matches_trigger(trigger, "gitlab", "merge_request.open", payload) is True + + def test_match_note_with_icontains(self): + """Filter note by case-insensitive body match.""" + payload = self._note_payload("Please @OpenHands help with this") + trigger = EventTrigger( + source="gitlab", + on="note", + filter="icontains(object_attributes.note, '@openhands')", + ) + + assert matches_trigger(trigger, "gitlab", "note", payload) is True + + def test_no_match_different_source(self): + """GitLab trigger should not match GitHub events.""" + payload = self._mr_payload() + trigger = EventTrigger(source="github", on="pull_request.opened") + + assert ( + matches_trigger(trigger, "gitlab", "merge_request.open", payload) is False + ) + + def test_no_match_filter_mismatch(self): + """Filter that doesn't match should not trigger.""" + payload = self._mr_payload(project="org/test-repo") + trigger = EventTrigger( + source="gitlab", + on="merge_request.open", + filter="project.path_with_namespace == 'different/repo'", + ) + + assert ( + matches_trigger(trigger, "gitlab", "merge_request.open", payload) is False + ) From c5d0940b8cea390cc8977dfffb9f516c9a8bac6e Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 27 Apr 2026 16:09:20 +0000 Subject: [PATCH 2/4] fix: Update reserved sources test for GitLab support Update test_only_github_reserved to test_builtin_providers_reserved and add test_gitlab_is_reserved to properly test that both GitHub and GitLab are in the RESERVED_SOURCES set. Co-authored-by: openhands --- tests/test_webhook_router.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/test_webhook_router.py b/tests/test_webhook_router.py index caa5ff9..feba982 100644 --- a/tests/test_webhook_router.py +++ b/tests/test_webhook_router.py @@ -198,9 +198,13 @@ 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_gitlab_is_reserved(self): + """GitLab should be reserved.""" + assert "gitlab" in RESERVED_SOURCES + + def test_builtin_providers_reserved(self): + """Only builtin Git providers should be reserved.""" + assert RESERVED_SOURCES == {"github", "gitlab"} class TestWebhookSecretGeneration: From dddf34fa745082feef249803ad2c2d2bfe55fe51 Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 27 Apr 2026 16:13:03 +0000 Subject: [PATCH 3/4] style: Fix formatting - remove extra blank lines Co-authored-by: openhands --- tests/test_event_router.py | 1 - tests/test_event_schemas.py | 1 - 2 files changed, 2 deletions(-) diff --git a/tests/test_event_router.py b/tests/test_event_router.py index 3d619f3..754f5a3 100644 --- a/tests/test_event_router.py +++ b/tests/test_event_router.py @@ -400,7 +400,6 @@ async def test_receive_unknown_source( assert "Unknown webhook source" in response.json()["detail"] - # ============================================================================= # GitLab Event Tests # ============================================================================= diff --git a/tests/test_event_schemas.py b/tests/test_event_schemas.py index 7901f02..aa73e9e 100644 --- a/tests/test_event_schemas.py +++ b/tests/test_event_schemas.py @@ -907,7 +907,6 @@ def test_rules_cover_all_payload_classes(self): assert "release" in supported - # ============================================================================= # GitLab Event Tests # ============================================================================= From 45e2c2ecc6c9c6c03aae8db1fa63dbeb5e9043fc Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 27 Apr 2026 16:43:05 +0000 Subject: [PATCH 4/4] chore: Address PR review feedback - remove dead code Remove fallback detection rules that can never execute since GitLab webhooks always include the object_kind field. The second comment about action extraction duplication is acknowledged but kept as-is since it's minor and doesn't warrant added complexity of a base class mixin. Co-authored-by: openhands --- automation/event_schemas/gitlab.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/automation/event_schemas/gitlab.py b/automation/event_schemas/gitlab.py index 50790a7..dee5faf 100644 --- a/automation/event_schemas/gitlab.py +++ b/automation/event_schemas/gitlab.py @@ -401,26 +401,14 @@ def action(self) -> str: # ============================================================================= # Detection rules: (event_type, jmespath_expression) -# GitLab events have an `object_kind` field that identifies the event type +# GitLab webhooks always include `object_kind` field that identifies the event type GITLAB_DETECTION_RULES: list[tuple[str, str]] = [ - # GitLab payloads have explicit object_kind field ("merge_request", "object_kind == 'merge_request'"), ("push", "object_kind == 'push'"), ("tag_push", "object_kind == 'tag_push'"), ("issue", "object_kind == 'issue'"), ("note", "object_kind == 'note'"), ("pipeline", "object_kind == 'pipeline'"), - # Fallback detection using payload structure (when object_kind is absent) - ( - "merge_request", - "contains(keys(@), 'object_attributes') && contains(keys(@), 'merge_request')", - ), - ( - "issue", - "contains(keys(@), 'object_attributes') && " - "object_attributes.noteable_type == null && " - "!contains(keys(@), 'merge_request')", - ), ] # Lazy-initialized detector (created on first use)