diff --git a/integrations/jira/CHANGELOG.md b/integrations/jira/CHANGELOG.md index 98465b0760..fd5bad9cab 100644 --- a/integrations/jira/CHANGELOG.md +++ b/integrations/jira/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +## 0.3.2 (2025-02-24) + + +### Improvements + +- Added support for OAuth live events for Jira using the webhooks api. + - https://developer.atlassian.com/cloud/jira/platform/webhooks/#registering-a-webhook-using-the-rest-api--for-connect-and-oauth-2-0-apps- + + ## 0.3.1 (2025-02-23) diff --git a/integrations/jira/jira/client.py b/integrations/jira/jira/client.py index c49c66adf8..febc17175b 100644 --- a/integrations/jira/jira/client.py +++ b/integrations/jira/jira/client.py @@ -1,12 +1,12 @@ import asyncio +import uuid from typing import Any, AsyncGenerator, Generator import httpx from httpx import Auth, BasicAuth, Request, Response, Timeout from loguru import logger -from port_ocean.clients.auth.oauth_client import ( - OAuthClient, -) + +from port_ocean.clients.auth.oauth_client import OAuthClient from port_ocean.context.ocean import ocean from port_ocean.utils import http_async_client @@ -30,6 +30,12 @@ "user_deleted", ] +OAUTH2_WEBHOOK_EVENTS = [ + "jira:issue_created", + "jira:issue_updated", + "jira:issue_deleted", +] + class BearerAuth(Auth): def __init__(self, token: str): @@ -51,20 +57,24 @@ def __init__(self, jira_url: str, jira_email: str, jira_token: str) -> None: self.jira_token = jira_token # If the Jira URL is directing to api.atlassian.com, we use OAuth2 Bearer Auth - if "api.atlassian.com" in self.jira_url: + if self.is_oauth_host(): self.jira_api_auth = self._get_bearer() + self.webhooks_url = f"{self.jira_rest_url}/api/3/webhook" else: self.jira_api_auth = BasicAuth(self.jira_email, self.jira_token) + self.webhooks_url = f"{self.jira_rest_url}/webhooks/1.0/webhook" self.api_url = f"{self.jira_rest_url}/api/3" self.teams_base_url = f"{self.jira_url}/gateway/api/public/teams/v1/org" - self.webhooks_url = f"{self.jira_rest_url}/webhooks/1.0/webhook" self.client = http_async_client self.client.auth = self.jira_api_auth self.client.timeout = Timeout(30) self._semaphore = asyncio.Semaphore(MAX_CONCURRENT_REQUESTS) + def is_oauth_host(self) -> bool: + return "api.atlassian.com" in self.jira_url + def _get_bearer(self) -> BearerAuth: try: return BearerAuth(self.external_access_token) @@ -169,12 +179,41 @@ def _generate_base_req_params( "startAt": startAt, } - async def _get_webhooks(self) -> list[dict[str, Any]]: - return await self._send_api_request("GET", url=self.webhooks_url) + async def _create_events_webhook_oauth(self, app_host: str) -> None: + webhook_target_app_host = f"{app_host}/integration/webhook" + webhooks = (await self._send_api_request("GET", url=self.webhooks_url)).get( + "values" + ) + + if webhooks: + logger.info("Ocean real time reporting webhook already exists") + return + + # We search a random project to get data from all projects + random_project = str(uuid.uuid4()) + + body = { + "url": webhook_target_app_host, + "webhooks": [ + { + "jqlFilter": f"project not in ({random_project})", + "events": OAUTH2_WEBHOOK_EVENTS, + } + ], + } + + await self._send_api_request("POST", self.webhooks_url, json=body) + logger.info("Ocean real time reporting webhook created") + + async def create_webhooks(self, app_host: str) -> None: + if self.is_oauth_host(): + await self._create_events_webhook_oauth(app_host) + else: + await self._create_events_webhook(app_host) - async def create_events_webhook(self, app_host: str) -> None: + async def _create_events_webhook(self, app_host: str) -> None: webhook_target_app_host = f"{app_host}/integration/webhook" - webhooks = await self._get_webhooks() + webhooks = await self._send_api_request("GET", url=self.webhooks_url) for webhook in webhooks: if webhook.get("url") == webhook_target_app_host: diff --git a/integrations/jira/main.py b/integrations/jira/main.py index dbdccfb49c..4ce1a7f4b5 100644 --- a/integrations/jira/main.py +++ b/integrations/jira/main.py @@ -25,7 +25,7 @@ async def setup_application() -> None: return client = create_jira_client() - await client.create_events_webhook(base_url) + await client.create_webhooks(base_url) @ocean.on_resync(Kinds.PROJECT) diff --git a/integrations/jira/pyproject.toml b/integrations/jira/pyproject.toml index cf0450a978..1f06545edd 100644 --- a/integrations/jira/pyproject.toml +++ b/integrations/jira/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "jira" -version = "0.3.1" +version = "0.3.2" description = "Integration to bring information from Jira into Port" authors = ["Mor Paz "] diff --git a/integrations/jira/tests/test_client.py b/integrations/jira/tests/test_client.py index d743498273..fba6b1a664 100644 --- a/integrations/jira/tests/test_client.py +++ b/integrations/jira/tests/test_client.py @@ -273,7 +273,7 @@ async def test_create_events_webhook(mock_jira_client: JiraClient) -> None: {"id": "new_webhook"}, # Creation response ] - await mock_jira_client.create_events_webhook(app_host) + await mock_jira_client.create_webhooks(app_host) # Verify webhook creation call create_call = mock_request.call_args_list[1] @@ -288,5 +288,5 @@ async def test_create_events_webhook(mock_jira_client: JiraClient) -> None: ) as mock_request: mock_request.return_value = [{"url": webhook_url}] - await mock_jira_client.create_events_webhook(app_host) + await mock_jira_client.create_webhooks(app_host) mock_request.assert_called_once() # Only checks for existence