diff --git a/.github/workflows/integrations-test.yml b/.github/workflows/integrations-test.yml index 41d9fc9a38..ec9f7ed0de 100644 --- a/.github/workflows/integrations-test.yml +++ b/.github/workflows/integrations-test.yml @@ -37,7 +37,7 @@ jobs: - name: Test working-directory: ${{ format('integrations/{0}', matrix.folder) }} env: - PYTEST_ADDOPTS: --junitxml=junit/test-results-${{ format('integrations/{0}', matrix.folder) }}.xml + PYTEST_ADDOPTS: --junitxml=junit/test-results-${{ format('integrations/{0}', matrix.folder) }}.xml --dist=no run: | make test diff --git a/integrations/jira/.port/resources/blueprints.json b/integrations/jira/.port/resources/blueprints.json index 9a58db66ce..f3b7deaadc 100644 --- a/integrations/jira/.port/resources/blueprints.json +++ b/integrations/jira/.port/resources/blueprints.json @@ -11,10 +11,17 @@ "type": "string", "format": "url", "description": "URL to the project in Jira" + }, + "totalIssues": { + "title": "Total Issues", + "type": "number", + "description": "The total number of issues in the project" } } }, - "calculationProperties": {} + "mirrorProperties": {}, + "calculationProperties": {}, + "relations": {} }, { "identifier": "jiraUser", diff --git a/integrations/jira/.port/resources/port-app-config.yaml b/integrations/jira/.port/resources/port-app-config.yaml index 97139b55b9..be109af04d 100644 --- a/integrations/jira/.port/resources/port-app-config.yaml +++ b/integrations/jira/.port/resources/port-app-config.yaml @@ -12,6 +12,8 @@ resources: blueprint: '"jiraProject"' properties: url: (.self | split("/") | .[:3] | join("/")) + "/projects/" + .key + totalIssues: .insight.totalIssueCount + - kind: user selector: @@ -47,7 +49,7 @@ resources: issueType: .fields.issuetype.name components: .fields.components creator: .fields.creator.emailAddress - priority: .fields.priority.id + priority: .fields.priority.name labels: .fields.labels created: .fields.created updated: .fields.updated diff --git a/integrations/jira/CHANGELOG.md b/integrations/jira/CHANGELOG.md index 4f0e489da8..f9f9cdd140 100644 --- a/integrations/jira/CHANGELOG.md +++ b/integrations/jira/CHANGELOG.md @@ -47,6 +47,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bumped ocean version to ^0.14.5 +## 0.1.104 (2024-11-27) + + +### Features + +- Added a field to display total issues in a project +- Added support for ingesting other fields apart from the default fields (Jira Sprint support) + +### Improvements + +- Changed issue priority from id to name + + ## 0.1.103 (2024-11-25) diff --git a/integrations/jira/client.py b/integrations/jira/client.py new file mode 100644 index 0000000000..5266857d9d --- /dev/null +++ b/integrations/jira/client.py @@ -0,0 +1,183 @@ +import typing +from typing import Any, AsyncGenerator, Generator + +import httpx +from httpx import Auth, BasicAuth, Request, Response, Timeout +from loguru import logger +from port_ocean.context.ocean import ocean +from port_ocean.utils import http_async_client + +PAGE_SIZE = 50 +WEBHOOK_NAME = "Port-Ocean-Events-Webhook" +REQUEST_TIMEOUT = 120 + + +CREATE_UPDATE_WEBHOOK_EVENTS = [ + "jira:issue_created", + "jira:issue_updated", + "project_created", + "project_updated", + "project_restored_deleted", + "project_restored_archived", + "user_created", + "user_updated", +] + +DELETE_WEBHOOK_EVENTS = [ + "jira:issue_deleted", + "project_deleted", + "project_soft_deleted", + "project_archived", + "user_deleted", +] + +WEBHOOK_EVENTS = [ + *CREATE_UPDATE_WEBHOOK_EVENTS, + *DELETE_WEBHOOK_EVENTS, +] + + +class BearerAuth(Auth): + def __init__(self, token: str): + self.token = token + + def auth_flow(self, request: Request) -> Generator[Request, Response, None]: + request.headers["Authorization"] = f"Bearer {self.token}" + yield request + + +class JiraClient: + def __init__(self, jira_url: str, jira_email: str, jira_token: str) -> None: + self.jira_url = jira_url + self.agile_url = f"{self.jira_url}/rest/agile/1.0" + self.jira_rest_url = f"{self.jira_url}/rest" + self.detail_base_url = f"{self.jira_rest_url}/api/3" + + # If the Jira URL is directing to api.atlassian.com, we use OAuth2 Bearer Auth + self.jira_api_auth: Auth + if "api.atlassian.com" in self.jira_url: + self.jira_api_auth = BearerAuth(jira_token) + else: + self.jira_api_auth = BasicAuth(jira_email, jira_token) + 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(REQUEST_TIMEOUT) + + @staticmethod + def _generate_base_req_params( + maxResults: int = 50, startAt: int = 0 + ) -> dict[str, Any]: + return { + "maxResults": maxResults, + "startAt": startAt, + } + + async def _make_paginated_request( + self, + url: str, + params: dict[str, Any] = {}, + is_last_function: typing.Callable[ + [dict[str, Any]], bool + ] = lambda response: response["isLast"], + ) -> AsyncGenerator[dict[str, list[dict[str, Any]]], None]: + params = {**self._generate_base_req_params(), **params} + is_last = False + logger.info(f"Making paginated request to {url} with params: {params}") + while not is_last: + try: + response = await self.client.get(url, params=params) + response.raise_for_status() + response_data = response.json() + yield response_data + is_last = is_last_function(response_data) + start = response_data["startAt"] + response_data["maxResults"] + params = {**params, "startAt": start} + logger.info(f"Next page startAt: {start}") + except httpx.HTTPStatusError as e: + + logger.error( + f"HTTP error with status code: {e.response.status_code}" + f" and response text: {e.response.text}" + ) + raise + except httpx.HTTPError as e: + logger.error(f"HTTP occurred while fetching Jira data {e}") + raise + logger.info("Finished paginated request") + return + + async def get_all_projects(self) -> AsyncGenerator[list[dict[str, Any]], None]: + async for projects in self._make_paginated_request( + f"{self.detail_base_url}/project/search" + ): + yield projects["values"] + + async def get_all_issues( + self, + params: dict[str, Any] = {}, + ) -> AsyncGenerator[list[dict[str, Any]], None]: + async for issues in self._make_paginated_request( + f"{self.detail_base_url}/search", + params=params, + is_last_function=lambda response: response["startAt"] + + response["maxResults"] + >= response["total"], + ): + yield issues["issues"] + + async def get_all_users(self, params: dict[str, Any] = {}) -> list[dict[str, Any]]: + return await self._get_single_item( + f"{self.detail_base_url}/users", params=params + ) + + async def _get_single_item(self, url: str, params: dict[str, Any] = {}) -> Any: + try: + response = await self.client.get(url, params=params) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + logger.error( + f"HTTP error on {url}: {e.response.status_code} - {e.response.text}" + ) + raise + except httpx.HTTPError as e: + logger.error(f"HTTP occurred while fetching Jira data {e}") + raise + + async def get_single_project(self, project: str) -> dict[str, Any]: + return await self._get_single_item(f"{self.detail_base_url}/project/{project}") + + async def get_single_issue( + self, issue: str, fields: dict[str, Any] = {}, jql: str | None = None + ) -> dict[str, Any]: + return await self._get_single_item(f"{self.agile_url}/issue/{issue}") + + async def get_single_user(self, account_id: str) -> dict[str, Any]: + return await self._get_single_item( + f"{self.detail_base_url}/user", params={"accountId": account_id} + ) + + async def create_events_webhook(self, app_host: str) -> None: + webhook_target_app_host = f"{app_host}/integration/webhook" + webhook_check_response = await self.client.get(f"{self.webhooks_url}") + webhook_check_response.raise_for_status() + webhook_check = webhook_check_response.json() + + for webhook in webhook_check: + if webhook["url"] == webhook_target_app_host: + logger.info("Ocean real time reporting webhook already exists") + return + + body = { + "name": f"{ocean.config.integration.identifier}-{WEBHOOK_NAME}", + "url": webhook_target_app_host, + "events": WEBHOOK_EVENTS, + } + + webhook_create_response = await self.client.post( + f"{self.webhooks_url}", json=body + ) + webhook_create_response.raise_for_status() + logger.info("Ocean real time reporting webhook created") diff --git a/integrations/jira/integration.py b/integrations/jira/integration.py index 9aceee959b..c28a92af0a 100644 --- a/integrations/jira/integration.py +++ b/integrations/jira/integration.py @@ -1,7 +1,7 @@ from port_ocean.core.handlers.port_app_config.api import APIPortAppConfig from port_ocean.core.integrations.base import BaseIntegration -from jira.overrides import JiraPortAppConfig +from overrides import JiraPortAppConfig class JiraIntegration(BaseIntegration): diff --git a/integrations/jira/jira/__init__.py b/integrations/jira/jira/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/integrations/jira/jira/client.py b/integrations/jira/jira/client.py deleted file mode 100644 index 6aa8f6df4a..0000000000 --- a/integrations/jira/jira/client.py +++ /dev/null @@ -1,209 +0,0 @@ -import typing -from typing import Any, AsyncGenerator, Generator - -from httpx import Timeout, Auth, BasicAuth, Request, Response -from jira.overrides import JiraResourceConfig -from loguru import logger - -from port_ocean.context.event import event -from port_ocean.context.ocean import ocean -from port_ocean.utils import http_async_client - -PAGE_SIZE = 50 -WEBHOOK_NAME = "Port-Ocean-Events-Webhook" - -WEBHOOK_EVENTS = [ - "jira:issue_created", - "jira:issue_updated", - "jira:issue_deleted", - "project_created", - "project_updated", - "project_deleted", - "project_soft_deleted", - "project_restored_deleted", - "project_archived", - "project_restored_archived", - "user_created", - "user_updated", - "user_deleted", -] - - -class BearerAuth(Auth): - def __init__(self, token: str): - self.token = token - - def auth_flow(self, request: Request) -> Generator[Request, Response, None]: - request.headers["Authorization"] = f"Bearer {self.token}" - yield request - - -class JiraClient: - jira_api_auth: Auth - - def __init__(self, jira_url: str, jira_email: str, jira_token: str) -> None: - self.jira_url = jira_url - self.jira_rest_url = f"{self.jira_url}/rest" - self.jira_email = jira_email - 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: - self.jira_api_auth = BearerAuth(self.jira_token) - else: - self.jira_api_auth = BasicAuth(self.jira_email, self.jira_token) - - self.api_url = f"{self.jira_rest_url}/api/3" - 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) - - @staticmethod - def _generate_base_req_params( - maxResults: int = 0, startAt: int = 0 - ) -> dict[str, Any]: - return { - "maxResults": maxResults, - "startAt": startAt, - } - - async def _get_paginated_projects(self, params: dict[str, Any]) -> dict[str, Any]: - project_response = await self.client.get( - f"{self.api_url}/project/search", params=params - ) - project_response.raise_for_status() - return project_response.json() - - async def _get_paginated_issues(self, params: dict[str, Any]) -> dict[str, Any]: - issue_response = await self.client.get(f"{self.api_url}/search", params=params) - issue_response.raise_for_status() - return issue_response.json() - - async def _get_users_data(self, params: dict[str, Any]) -> list[dict[str, Any]]: - user_response = await self.client.get(f"{self.api_url}/users", params=params) - user_response.raise_for_status() - return user_response.json() - - async def create_events_webhook(self, app_host: str) -> None: - webhook_target_app_host = f"{app_host}/integration/webhook" - webhook_check_response = await self.client.get(f"{self.webhooks_url}") - webhook_check_response.raise_for_status() - webhook_check = webhook_check_response.json() - - for webhook in webhook_check: - if webhook["url"] == webhook_target_app_host: - logger.info("Ocean real time reporting webhook already exists") - return - - body = { - "name": f"{ocean.config.integration.identifier}-{WEBHOOK_NAME}", - "url": webhook_target_app_host, - "events": WEBHOOK_EVENTS, - } - - webhook_create_response = await self.client.post( - f"{self.webhooks_url}", json=body - ) - webhook_create_response.raise_for_status() - logger.info("Ocean real time reporting webhook created") - - async def get_single_project(self, project_key: str) -> dict[str, Any]: - project_response = await self.client.get( - f"{self.api_url}/project/{project_key}" - ) - project_response.raise_for_status() - return project_response.json() - - async def get_paginated_projects( - self, - ) -> AsyncGenerator[list[dict[str, Any]], None]: - logger.info("Getting projects from Jira") - - params = self._generate_base_req_params() - - total_projects = (await self._get_paginated_projects(params))["total"] - - if total_projects == 0: - logger.warning( - "Project query returned 0 projects, did you provide the correct Jira API credentials?" - ) - - params["maxResults"] = PAGE_SIZE - while params["startAt"] <= total_projects: - logger.info(f"Current query position: {params['startAt']}/{total_projects}") - project_response_list = (await self._get_paginated_projects(params))[ - "values" - ] - yield project_response_list - params["startAt"] += PAGE_SIZE - - async def get_single_issue(self, issue_key: str) -> dict[str, Any]: - issue_response = await self.client.get(f"{self.api_url}/issue/{issue_key}") - issue_response.raise_for_status() - return issue_response.json() - - async def get_paginated_issues(self) -> AsyncGenerator[list[dict[str, Any]], None]: - logger.info("Getting issues from Jira") - - params = self._generate_base_req_params() - - config = typing.cast(JiraResourceConfig, event.resource_config) - - if config.selector.jql: - params["jql"] = config.selector.jql - logger.info(f"Found JQL filter: {config.selector.jql}") - - total_issues = (await self._get_paginated_issues(params))["total"] - - if total_issues == 0: - logger.warning( - "Issue query returned 0 issues, did you provide the correct Jira API credentials and JQL query?" - ) - - params["maxResults"] = PAGE_SIZE - while params["startAt"] <= total_issues: - logger.info(f"Current query position: {params['startAt']}/{total_issues}") - issue_response_list = (await self._get_paginated_issues(params))["issues"] - yield issue_response_list - params["startAt"] += PAGE_SIZE - - async def get_paginated_users( - self, - ) -> AsyncGenerator[list[dict[str, Any]], None]: - logger.info("Getting users from Jira") - - params = self._generate_base_req_params() - - total_users = len(await self._get_users_data(params)) - - if total_users == 0: - logger.warning( - "User query returned 0 users, did you provide the correct Jira API credentials?" - ) - - params["maxResults"] = PAGE_SIZE - while params["startAt"] < total_users: - logger.info(f"Current query position: {params['startAt']}/{total_users}") - - user_response_list = await self._get_users_data(params) - - if not user_response_list: - logger.warning(f"No users found at {params['startAt']}") - break - - logger.info( - f"Retrieved users: {len(user_response_list)} " - f"(Position: {params['startAt']}/{total_users})" - ) - - yield user_response_list - params["startAt"] += PAGE_SIZE - - async def get_single_user(self, account_id: str) -> dict[str, Any]: - user_response = await self.client.get( - f"{self.api_url}/user", params={"accountId": account_id} - ) - user_response.raise_for_status() - return user_response.json() diff --git a/integrations/jira/jira/overrides.py b/integrations/jira/jira/overrides.py deleted file mode 100644 index 85edd71838..0000000000 --- a/integrations/jira/jira/overrides.py +++ /dev/null @@ -1,17 +0,0 @@ -from port_ocean.core.handlers.port_app_config.models import ( - PortAppConfig, - ResourceConfig, -) -from pydantic import BaseModel - - -class JiraResourceConfig(ResourceConfig): - class Selector(BaseModel): - query: str - jql: str | None = None - - selector: Selector # type: ignore - - -class JiraPortAppConfig(PortAppConfig): - resources: list[JiraResourceConfig] # type: ignore diff --git a/integrations/jira/main.py b/integrations/jira/main.py index 600072f78d..14293a1a9f 100644 --- a/integrations/jira/main.py +++ b/integrations/jira/main.py @@ -1,11 +1,15 @@ +import typing from enum import StrEnum from typing import Any -from jira.client import JiraClient from loguru import logger +from port_ocean.context.event import event from port_ocean.context.ocean import ocean from port_ocean.core.ocean_types import ASYNC_GENERATOR_RESYNC_TYPE +from client import CREATE_UPDATE_WEBHOOK_EVENTS, DELETE_WEBHOOK_EVENTS, JiraClient +from overrides import JiraIssueResourceConfig, JiraIssueSelector, JiraPortAppConfig + class ObjectKind(StrEnum): PROJECT = "project" @@ -13,6 +17,14 @@ class ObjectKind(StrEnum): USER = "user" +def initialize_client() -> JiraClient: + return JiraClient( + ocean.integration_config["jira_host"], + ocean.integration_config["atlassian_user_email"], + ocean.integration_config["atlassian_user_token"], + ) + + async def setup_application() -> None: logic_settings = ocean.integration_config app_host = logic_settings.get("app_host") @@ -23,11 +35,7 @@ async def setup_application() -> None: ) return - jira_client = JiraClient( - logic_settings["jira_host"], - logic_settings["atlassian_user_email"], - logic_settings["atlassian_user_token"], - ) + jira_client = initialize_client() await jira_client.create_events_webhook( logic_settings["app_host"], @@ -36,26 +44,25 @@ async def setup_application() -> None: @ocean.on_resync(ObjectKind.PROJECT) async def on_resync_projects(kind: str) -> ASYNC_GENERATOR_RESYNC_TYPE: - client = JiraClient( - ocean.integration_config["jira_host"], - ocean.integration_config["atlassian_user_email"], - ocean.integration_config["atlassian_user_token"], - ) + client = initialize_client() - async for projects in client.get_paginated_projects(): - logger.info(f"Received project batch with {len(projects)} issues") + async for projects in client.get_all_projects(): + logger.info(f"Received project batch with {len(projects)} projects") yield projects @ocean.on_resync(ObjectKind.ISSUE) async def on_resync_issues(kind: str) -> ASYNC_GENERATOR_RESYNC_TYPE: - client = JiraClient( - ocean.integration_config["jira_host"], - ocean.integration_config["atlassian_user_email"], - ocean.integration_config["atlassian_user_token"], - ) + client = initialize_client() + config = typing.cast(JiraIssueResourceConfig, event.resource_config).selector + params = {} + if config.jql: + params["jql"] = config.jql - async for issues in client.get_paginated_issues(): + if config.fields: + params["fields"] = config.fields + + async for issues in client.get_all_issues(params): logger.info(f"Received issue batch with {len(issues)} issues") yield issues @@ -68,63 +75,92 @@ async def on_resync_users(kind: str) -> ASYNC_GENERATOR_RESYNC_TYPE: ocean.integration_config["atlassian_user_token"], ) - async for users in client.get_paginated_users(): - logger.info(f"Received users batch with {len(users)} users") - yield users + users = await client.get_all_users() + + logger.info(f"Received user batch with {len(users)} users") + + yield users @ocean.router.post("/webhook") async def handle_webhook_request(data: dict[str, Any]) -> dict[str, Any]: - client = JiraClient( - ocean.integration_config["jira_host"], - ocean.integration_config["atlassian_user_email"], - ocean.integration_config["atlassian_user_token"], - ) - - webhook_event = data.get("webhookEvent") - if not webhook_event: - logger.error("Missing webhook event") - return {"ok": False, "error": "Missing webhook event"} - - logger.info(f"Processing webhook event: {webhook_event}") - - match webhook_event: - case event if event.startswith("user_"): - account_id = data["user"]["accountId"] - logger.debug(f"Fetching user with accountId: {account_id}") - item = await client.get_single_user(account_id) - kind = ObjectKind.USER - case event if event.startswith("project_"): - project_key = data["project"]["key"] - logger.debug(f"Fetching project with key: {project_key}") - item = await client.get_single_project(project_key) - kind = ObjectKind.PROJECT - case event if event.startswith("jira:issue_"): - issue_key = data["issue"]["key"] - logger.debug(f"Fetching issue with key: {issue_key}") - item = await client.get_single_issue(issue_key) - kind = ObjectKind.ISSUE - case _: - logger.error(f"Unknown webhook event type: {webhook_event}") - return { - "ok": False, - "error": f"Unknown webhook event type: {webhook_event}", - } - - if not item: - logger.error("Failed to retrieve item") - return {"ok": False, "error": "Failed to retrieve item"} - - logger.debug(f"Retrieved {kind} item: {item}") - - if "deleted" in webhook_event: - logger.info(f"Unregistering {kind} item") - await ocean.unregister_raw(kind, [item]) - else: - logger.info(f"Registering {kind} item") - await ocean.register_raw(kind, [item]) - - logger.info(f"Webhook event '{webhook_event}' processed successfully") + client = initialize_client() + webhook_event: str = data.get("webhookEvent", "") + logger.info(f"Received webhook event of type: {webhook_event}") + ocean_action = None + delete_action = False + + if webhook_event in DELETE_WEBHOOK_EVENTS: + ocean_action = ocean.unregister_raw + delete_action = True + elif webhook_event in CREATE_UPDATE_WEBHOOK_EVENTS: + ocean_action = ocean.register_raw + + if not ocean_action: + logger.info("Webhook event not recognized") + return {"ok": True} + + if "project" in webhook_event: + logger.info(f'Received webhook event for project: {data["project"]["key"]}') + if delete_action: + project = data["project"] + else: + project = await client.get_single_project(data["project"]["key"]) + await ocean_action(ObjectKind.PROJECT, [project]) + + elif "user" in webhook_event: + logger.info(f'Received webhook event for user: {data["user"]["accountId"]}') + if delete_action: + user = data["user"] + else: + user = await client.get_single_user(data["user"]["accountId"]) + await ocean_action(ObjectKind.USER, [user]) + + elif "issue" in webhook_event: + logger.info(f'Received webhook event for issue: {data["issue"]["key"]}') + if delete_action: + issue = data["issue"] + await ocean.unregister_raw(ObjectKind.ISSUE, [issue]) + else: + resource_configs = typing.cast( + JiraPortAppConfig, event.port_app_config + ).resources + + matching_resource_configs = [ + resource_config + for resource_config in resource_configs + if ( + resource_config.kind == ObjectKind.ISSUE + and isinstance(resource_config.selector, JiraIssueSelector) + ) + ] + + for matching_resource_config in matching_resource_configs: + config = typing.cast( + JiraIssueResourceConfig, matching_resource_config + ).selector + + params = {} + + if config.jql: + params["jql"] = f"{config.jql} AND key = {data['issue']['key']}" + else: + params["jql"] = f"key = {data['issue']['key']}" + + issues: list[dict[str, Any]] = [] + async for issue in client.get_all_issues(params): + issues.append(issue) + + if not issues: + logger.warning( + f"Issue {data['issue']['key']} not found." + "This is likely due to JQL filter" + ) + await ocean.unregister_raw(ObjectKind.ISSUE, [data["issue"]]) + else: + issue = issues[0] + await ocean.register_raw(ObjectKind.ISSUE, [issue]) + logger.info("Webhook event processed") return {"ok": True} diff --git a/integrations/jira/overrides.py b/integrations/jira/overrides.py new file mode 100644 index 0000000000..6c00b6e0ec --- /dev/null +++ b/integrations/jira/overrides.py @@ -0,0 +1,29 @@ +from typing import Literal + +from port_ocean.core.handlers.port_app_config.models import ( + PortAppConfig, + ResourceConfig, + Selector, +) +from pydantic.fields import Field + + +class JiraIssueSelector(Selector): + jql: str | None = Field( + description="Jira Query Language (JQL) query to filter issues", + ) + fields: str | None = Field( + description="Additional fields to be included in the API response", + default="*all", + ) + + +class JiraIssueResourceConfig(ResourceConfig): + kind: Literal["issue"] + selector: JiraIssueSelector + + +class JiraPortAppConfig(PortAppConfig): + resources: list[JiraIssueResourceConfig | ResourceConfig] = Field( + default_factory=list + ) diff --git a/integrations/jira/tests/conftest.py b/integrations/jira/tests/conftest.py new file mode 100644 index 0000000000..e914e49ea4 --- /dev/null +++ b/integrations/jira/tests/conftest.py @@ -0,0 +1,86 @@ +import os +from typing import Any, Generator +from unittest.mock import MagicMock, patch + +import pytest +from port_ocean import Ocean +from port_ocean.context.event import EventContext +from port_ocean.context.ocean import initialize_port_ocean_context +from port_ocean.exceptions.context import PortOceanContextAlreadyInitializedError +from port_ocean.tests.helpers.ocean_app import get_integration_ocean_app + +from overrides import JiraPortAppConfig + +from .fixtures import ISSUES, PROJECTS + +INTEGRATION_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), "../")) + + +@pytest.fixture() +def mock_ocean_context() -> None: + """Fixture to mock the Ocean context initialization.""" + try: + mock_ocean_app = MagicMock() + mock_ocean_app.config.integration.config = { + "jira_host": "https://getport.atlassian.net", + "atlassian_user_email": "jira@atlassian.net", + "atlassian_user_token": "asdf", + } + mock_ocean_app.integration_router = MagicMock() + mock_ocean_app.port_client = MagicMock() + initialize_port_ocean_context(mock_ocean_app) + except PortOceanContextAlreadyInitializedError: + pass + + +@pytest.fixture(scope="session") +def mock_event_context() -> Generator[MagicMock, None, None]: + """Fixture to mock the event context.""" + mock_event = MagicMock(spec=EventContext) + mock_event.event_type = "test_event" + mock_event.trigger_type = "manual" + mock_event.attributes = {} + mock_event._aborted = False + mock_event._port_app_config = JiraPortAppConfig + + with patch("port_ocean.context.event.event", mock_event): + yield mock_event + + +def app() -> Ocean: + config = { + "event_listener": {"type": "POLLING"}, + "integration": { + "config": { + "jira_host": "https://getport.atlassian.net", + "atlassian_user_email": "jira@atlassian.net", + "atlassian_user_token": "asdf", + } + }, + "port": { + "client_id": "bla", + "client_secret": "bla", + }, + } + application = get_integration_ocean_app(INTEGRATION_PATH, config) + return application + + +@pytest.fixture +def ocean_app() -> Ocean: + return app() + + +@pytest.fixture(scope="session") +def integration_path() -> str: + return INTEGRATION_PATH + + +@pytest.fixture(scope="session") +def projects() -> list[dict[str, Any]]: + return PROJECTS + + +@pytest.fixture(scope="session") +def issues() -> list[dict[str, Any]]: + return ISSUES diff --git a/integrations/jira/tests/fixtures.py b/integrations/jira/tests/fixtures.py new file mode 100644 index 0000000000..9b3d20b0f1 --- /dev/null +++ b/integrations/jira/tests/fixtures.py @@ -0,0 +1,1333 @@ +ISSUES = [ + { + "expand": "operations,versionedRepresentations,editmeta,changelog,customfield_10010.requestTypePractice,renderedFields", + "id": "17410", + "self": "https://testapp.atlassian.net/rest/api/3/issue/17410", + "key": "PROJ1-6847", + "fields": { + "statuscategorychangedate": "2024-03-22T11:58:25.928+0200", + "customfield_10075": None, + "customfield_10076": None, + "fixVersions": [], + "customfield_10110": None, + "customfield_10077": None, + "customfield_10078": None, + "customfield_10079": None, + "resolution": { + "self": "https://testapp.atlassian.net/rest/api/3/resolution/10000", + "id": "10000", + "description": "Work has been completed on this issue.", + "name": "Done", + }, + "customfield_10104": [], + "customfield_10105": None, + "customfield_10106": None, + "lastViewed": None, + "customfield_10100": None, + "priority": { + "self": "https://testapp.atlassian.net/rest/api/3/priority/2", + "iconUrl": "https://testapp.atlassian.net/images/icons/priorities/high.svg", + "name": "High", + "id": "2", + }, + "customfield_10101": None, + "customfield_10102": None, + "customfield_10103": None, + "labels": [], + "timeestimate": None, + "aggregatetimeoriginalestimate": None, + "versions": [], + "issuelinks": [], + "assignee": { + "self": "https://testapp.atlassian.net/rest/api/3/user?accountId=712020%3A77418b87-f1a0-434b-954c-466b807b4364", + "accountId": "712020:77418b87-f1a0-434b-954c-466b807b4364", + "emailAddress": "user@testapp.com", + "avatarUrls": { + "48x48": "https://secure.gravatar.com/avatar/a53b3c50458fd0d6252e3715c5777132?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FAA-5.png", + "24x24": "https://secure.gravatar.com/avatar/a53b3c50458fd0d6252e3715c5777132?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FAA-5.png", + "16x16": "https://secure.gravatar.com/avatar/a53b3c50458fd0d6252e3715c5777132?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FAA-5.png", + "32x32": "https://secure.gravatar.com/avatar/a53b3c50458fd0d6252e3715c5777132?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FAA-5.png", + }, + "displayName": "User User", + "active": True, + "timeZone": "Los Angeles/America", + "accountType": "atlassian", + }, + "status": { + "self": "https://testapp.atlassian.net/rest/api/3/status/10001", + "description": "", + "iconUrl": "https://testapp.atlassian.net/", + "name": "Done", + "id": "1000", + "statusCategory": { + "self": "https://testapp.atlassian.net/rest/api/3/statuscategory/3", + "id": 3, + "key": "done", + "colorName": "green", + "name": "Done", + }, + }, + "components": [], + "customfield_10050": None, + "customfield_10051": None, + "customfield_10052": None, + "customfield_10053": None, + "customfield_10054": None, + "customfield_10056": None, + "customfield_10057": None, + "customfield_10058": None, + "aggregatetimeestimate": None, + "creator": { + "self": "https://testapp.atlassian.net/rest/api/3/user?accountId=6242f78df813eb0069289616", + "accountId": "6242f78df813eb0069289616", + "emailAddress": "baba@testapp.com", + "avatarUrls": { + "48x48": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/6242f78df813eb0069289616/66327023-6dd5-4ac0-802c-b1cd361eb4d7/48", + "24x24": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/6242f78df813eb0069289616/66327023-6dd5-4ac0-802c-b1cd361eb4d7/24", + "16x16": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/6242f78df813eb0069289616/66327023-6dd5-4ac0-802c-b1cd361eb4d7/16", + "32x32": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/6242f78df813eb0069289616/66327023-6dd5-4ac0-802c-b1cd361eb4d7/32", + }, + "displayName": "User User", + "active": True, + "timeZone": "Los Angeles/America", + "accountType": "atlassian", + }, + "subtasks": [], + "reporter": { + "self": "https://testapp.atlassian.net/rest/api/3/user?accountId=6242f78df813eb0069289616", + "accountId": "6242f78df813eb0069289616", + "emailAddress": "user@testapp.com", + "avatarUrls": { + "48x48": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/6242f78df813eb0069289616/66327023-6dd5-4ac0-802c-b1cd361eb4d7/48", + "24x24": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/6242f78df813eb0069289616/66327023-6dd5-4ac0-802c-b1cd361eb4d7/24", + "16x16": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/6242f78df813eb0069289616/66327023-6dd5-4ac0-802c-b1cd361eb4d7/16", + "32x32": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/6242f78df813eb0069289616/66327023-6dd5-4ac0-802c-b1cd361eb4d7/32", + }, + "displayName": "User User", + "active": True, + "timeZone": "Los Angeles/America", + "accountType": "atlassian", + }, + "aggregateprogress": {"progress": 0, "total": 0}, + "progress": {"progress": 0, "total": 0}, + "votes": { + "self": "https://testapp.atlassian.net/rest/api/3/issue/PROJ1-6847/votes", + "votes": 0, + "hasVoted": False, + }, + "issuetype": { + "self": "https://testapp.atlassian.net/rest/api/3/issuetype/10002", + "id": "10002", + "description": "A small, distinct piece of work.", + "iconUrl": "https://testapp.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10318?size=medium", + "name": "Task", + "subtask": False, + "avatarId": 10318, + "hierarchyLevel": 0, + }, + "timespent": None, + "customfield_10030": None, + "project": { + "self": "https://testapp.atlassian.net/rest/api/3/project/10000", + "id": "10000", + "key": "PROJ1", + "name": "Port", + "projectTypeKey": "software", + "simplified": False, + "avatarUrls": { + "48x48": "https://testapp.atlassian.net/rest/api/3/universal_avatar/view/type/project/avatar/10560", + "24x24": "https://testapp.atlassian.net/rest/api/3/universal_avatar/view/type/project/avatar/10560?size=small", + "16x16": "https://testapp.atlassian.net/rest/api/3/universal_avatar/view/type/project/avatar/10560?size=xsmall", + "32x32": "https://testapp.atlassian.net/rest/api/3/universal_avatar/view/type/project/avatar/10560?size=medium", + }, + }, + "customfield_10031": None, + "customfield_10032": None, + "customfield_10033": None, + "aggregatetimespent": None, + "customfield_10029": [], + "resolutiondate": "2024-03-22T11:58:25.920+0200", + "workratio": -1, + "watches": { + "self": "https://testapp.atlassian.net/rest/api/3/issue/PROJ1-6847/watchers", + "watchCount": 1, + "isWatching": False, + }, + "created": "2024-02-21T16:13:01.254+0200", + "customfield_10020": [ + { + "id": 30, + "name": "Sprint 27", + "state": "closed", + "boardId": 1, + "goal": "", + "startDate": "2024-03-12T11:21:33.527Z", + "endDate": "2024-04-01T21:30:00.000Z", + "completeDate": "2024-04-02T08:15:37.055Z", + } + ], + "customfield_10021": None, + "customfield_10022": None, + "customfield_10023": None, + "customfield_10024": None, + "customfield_10025": "3_*:*_1_*:*_414335768_*|*_10000_*:*_1_*:*_111199955_*|*_10002_*:*_1_*:*_2051187009_*|*_10001_*:*_1_*:*_0_*|*_10003_*:*_1_*:*_2005", + "customfield_10026": None, + "customfield_10016": None, + "customfield_10017": None, + "customfield_10018": { + "hasEpicLinkFieldDependency": False, + "showField": False, + "nonEditableReason": { + "reason": "PLUGIN_LICENSE_ERROR", + "message": "The Parent Link is only available to Jira Premium users.", + }, + }, + "customfield_10019": "0|i00vin:", + "updated": "2024-03-22T11:58:25.927+0200", + "customfield_10090": None, + "customfield_10092": None, + "customfield_10093": None, + "customfield_10094": None, + "timeoriginalestimate": None, + "customfield_10095": None, + "customfield_10096": None, + "customfield_10097": None, + "description": { + "type": "doc", + "version": 1, + "content": [ + { + "type": "mediaSingle", + "attrs": {"layout": "align-start"}, + "content": [ + { + "type": "media", + "attrs": { + "type": "file", + "id": "ff358938-18bd-4cfa-aa83-d4c44ea447b2", + "collection": "", + "height": 498, + "width": 1035, + }, + } + ], + }, + { + "type": "paragraph", + "content": [ + {"type": "text", "text": "Jira ticket for example: "}, + { + "type": "inlineCard", + "attrs": { + "url": "https://testapp.atlassian.net/jira/software/c/projects/PROJ1/boards/1?selectedIssue=PROJ1-5976" + }, + }, + {"type": "text", "text": " "}, + {"type": "hardBreak"}, + {"type": "hardBreak"}, + { + "type": "mention", + "attrs": { + "id": "712020:05acda87-42da-44d8-b21e-f71a508e5d11", + "text": "@Isaac Coffie", + "accessLevel": "", + }, + }, + {"type": "text", "text": " "}, + ], + }, + ], + }, + "customfield_10010": None, + "customfield_10098": None, + "customfield_10099": None, + "customfield_10014": None, + "customfield_10015": None, + "customfield_10005": None, + "customfield_10006": None, + "security": None, + "customfield_10007": None, + "customfield_10008": None, + "customfield_10009": None, + "summary": "Implement feature 1 and 2", + "customfield_10082": None, + "customfield_10000": '{pullrequest={dataType=pullrequest, state=MERGED, stateCount=1}, json={"cachedValue":{"errors":[],"summary":{"pullrequest":{"overall":{"count":1,"lastUpdated":"2024-03-20T11:00:05.000+0200","stateCount":1,"state":"MERGED","dataType":"pullrequest","open":False},"byInstanceType":{"GitHub":{"count":1,"name":"GitHub"}}}}},"isStale":True}}', + "customfield_10089": None, + "customfield_10001": None, + "customfield_10002": [], + "customfield_10003": None, + "customfield_10004": None, + "environment": None, + "duedate": None, + }, + }, + { + "expand": "operations,versionedRepresentations,editmeta,changelog,customfield_10010.requestTypePractice,renderedFields", + "id": "17310", + "self": "https://testapp.atlassian.net/rest/api/3/issue/17310", + "key": "PROJ1-6747", + "fields": { + "statuscategorychangedate": "2024-05-26T14:50:00.923+0300", + "customfield_10075": None, + "customfield_10076": None, + "fixVersions": [], + "customfield_10110": None, + "customfield_10077": None, + "customfield_10078": None, + "customfield_10079": None, + "resolution": { + "self": "https://testapp.atlassian.net/rest/api/3/resolution/10000", + "id": "10000", + "description": "Work has been completed on this issue.", + "name": "Done", + }, + "customfield_10104": [], + "customfield_10105": None, + "customfield_10106": None, + "lastViewed": None, + "customfield_10100": None, + "priority": { + "self": "https://testapp.atlassian.net/rest/api/3/priority/2", + "iconUrl": "https://testapp.atlassian.net/images/icons/priorities/high.svg", + "name": "High", + "id": "2", + }, + "customfield_10101": None, + "customfield_10102": None, + "customfield_10103": None, + "labels": [], + "timeestimate": None, + "aggregatetimeoriginalestimate": None, + "versions": [], + "issuelinks": [], + "assignee": { + "self": "https://testapp.atlassian.net/rest/api/3/user?accountId=712020%3A77418b87-f1a0-434b-954c-466b807b4364", + "accountId": "712020:77418b87-f1a0-434b-954c-466b807b4364", + "emailAddress": "user@testapp.com", + "avatarUrls": { + "48x48": "https://secure.gravatar.com/avatar/a53b3c50458fd0d6252e3715c5777132?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FAA-5.png", + "24x24": "https://secure.gravatar.com/avatar/a53b3c50458fd0d6252e3715c5777132?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FAA-5.png", + "16x16": "https://secure.gravatar.com/avatar/a53b3c50458fd0d6252e3715c5777132?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FAA-5.png", + "32x32": "https://secure.gravatar.com/avatar/a53b3c50458fd0d6252e3715c5777132?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FAA-5.png", + }, + "displayName": "User User", + "active": True, + "timeZone": "Los Angeles/America", + "accountType": "atlassian", + }, + "status": { + "self": "https://testapp.atlassian.net/rest/api/3/status/10001", + "description": "", + "iconUrl": "https://testapp.atlassian.net/", + "name": "Done", + "id": "10001", + "statusCategory": { + "self": "https://testapp.atlassian.net/rest/api/3/statuscategory/3", + "id": 3, + "key": "done", + "colorName": "green", + "name": "Done", + }, + }, + "components": [], + "customfield_10050": None, + "customfield_10051": None, + "customfield_10052": None, + "customfield_10053": None, + "customfield_10054": None, + "customfield_10056": None, + "customfield_10057": None, + "customfield_10058": None, + "aggregatetimeestimate": None, + "creator": { + "self": "https://testapp.atlassian.net/rest/api/3/user?accountId=6242f78df813eb0069289616", + "accountId": "6242f78df813eb0069289616", + "emailAddress": "bogu@testapp.com", + "avatarUrls": { + "48x48": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/6242f78df813eb0069289616/66327023-6dd5-4ac0-802c-b1cd361eb4d7/48", + "24x24": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/6242f78df813eb0069289616/66327023-6dd5-4ac0-802c-b1cd361eb4d7/24", + "16x16": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/6242f78df813eb0069289616/66327023-6dd5-4ac0-802c-b1cd361eb4d7/16", + "32x32": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/6242f78df813eb0069289616/66327023-6dd5-4ac0-802c-b1cd361eb4d7/32", + }, + "displayName": "Yonatan Boguslavski", + "active": True, + "timeZone": "Los Angeles/America", + "accountType": "atlassian", + }, + "subtasks": [], + "reporter": { + "self": "https://testapp.atlassian.net/rest/api/3/user?accountId=6242f78df813eb0069289616", + "accountId": "6242f78df813eb0069289616", + "emailAddress": "bogu@testapp.com", + "avatarUrls": { + "48x48": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/6242f78df813eb0069289616/66327023-6dd5-4ac0-802c-b1cd361eb4d7/48", + "24x24": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/6242f78df813eb0069289616/66327023-6dd5-4ac0-802c-b1cd361eb4d7/24", + "16x16": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/6242f78df813eb0069289616/66327023-6dd5-4ac0-802c-b1cd361eb4d7/16", + "32x32": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/6242f78df813eb0069289616/66327023-6dd5-4ac0-802c-b1cd361eb4d7/32", + }, + "displayName": "Yonatan Boguslavski", + "active": True, + "timeZone": "Los Angeles/America", + "accountType": "atlassian", + }, + "aggregateprogress": {"progress": 0, "total": 0}, + "progress": {"progress": 0, "total": 0}, + "votes": { + "self": "https://testapp.atlassian.net/rest/api/3/issue/PROJ1-6747/votes", + "votes": 0, + "hasVoted": False, + }, + "issuetype": { + "self": "https://testapp.atlassian.net/rest/api/3/issuetype/10002", + "id": "10002", + "description": "A small, distinct piece of work.", + "iconUrl": "https://testapp.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10318?size=medium", + "name": "Task", + "subtask": False, + "avatarId": 10318, + "hierarchyLevel": 0, + }, + "timespent": None, + "customfield_10030": None, + "project": { + "self": "https://testapp.atlassian.net/rest/api/3/project/10000", + "id": "10000", + "key": "PROJ1", + "name": "Port", + "projectTypeKey": "software", + "simplified": False, + "avatarUrls": { + "48x48": "https://testapp.atlassian.net/rest/api/3/universal_avatar/view/type/project/avatar/10560", + "24x24": "https://testapp.atlassian.net/rest/api/3/universal_avatar/view/type/project/avatar/10560?size=small", + "16x16": "https://testapp.atlassian.net/rest/api/3/universal_avatar/view/type/project/avatar/10560?size=xsmall", + "32x32": "https://testapp.atlassian.net/rest/api/3/universal_avatar/view/type/project/avatar/10560?size=medium", + }, + }, + "customfield_10031": None, + "customfield_10032": None, + "customfield_10033": None, + "aggregatetimespent": None, + "customfield_10029": [], + "resolutiondate": "2024-05-26T14:50:00.915+0300", + "workratio": -1, + "watches": { + "self": "https://testapp.atlassian.net/rest/api/3/issue/PROJ1-6747/watchers", + "watchCount": 1, + "isWatching": False, + }, + "created": "2024-02-18T23:48:05.234+0200", + "customfield_10020": [ + { + "id": 32, + "name": "Sprint 28", + "state": "closed", + "boardId": 1, + "goal": "", + "startDate": "2024-04-02T08:15:07.681Z", + "endDate": "2024-04-22T20:00:00.000Z", + "completeDate": "2024-04-24T07:38:09.546Z", + }, + { + "id": 33, + "name": "Sprint 29", + "state": "closed", + "boardId": 1, + "goal": "", + "startDate": "2024-04-24T07:38:32.991Z", + "endDate": "2024-05-21T20:30:00.000Z", + "completeDate": "2024-05-22T07:34:36.617Z", + }, + { + "id": 34, + "name": "Sprint 30", + "state": "closed", + "boardId": 1, + "goal": "", + "startDate": "2024-05-21T07:35:04.197Z", + "endDate": "2024-06-10T20:30:00.000Z", + "completeDate": "2024-06-17T12:00:14.251Z", + }, + { + "id": 30, + "name": "Sprint 27", + "state": "closed", + "boardId": 1, + "goal": "", + "startDate": "2024-03-12T11:21:33.527Z", + "endDate": "2024-04-01T21:30:00.000Z", + "completeDate": "2024-04-02T08:15:37.055Z", + }, + ], + "customfield_10021": None, + "customfield_10022": None, + "customfield_10023": None, + "customfield_10024": None, + "customfield_10025": "3_*:*_2_*:*_237284125_*|*_10000_*:*_1_*:*_757449230_*|*_10002_*:*_2_*:*_7436582359_*|*_10001_*:*_1_*:*_0", + "customfield_10026": None, + "customfield_10016": None, + "customfield_10017": None, + "customfield_10018": { + "hasEpicLinkFieldDependency": False, + "showField": False, + "nonEditableReason": { + "reason": "PLUGIN_LICENSE_ERROR", + "message": "The Parent Link is only available to Jira Premium users.", + }, + }, + "customfield_10019": "0|i00ul3:", + "updated": "2024-05-26T14:50:00.923+0300", + "customfield_10090": None, + "customfield_10092": None, + "customfield_10093": None, + "customfield_10094": None, + "timeoriginalestimate": None, + "customfield_10095": None, + "customfield_10096": None, + "customfield_10097": None, + "description": None, + "customfield_10010": None, + "customfield_10098": None, + "customfield_10099": None, + "customfield_10014": None, + "customfield_10015": None, + "customfield_10005": None, + "customfield_10006": None, + "security": None, + "customfield_10007": None, + "customfield_10008": None, + "customfield_10009": None, + "summary": "Implement feature 3 and 4", + "customfield_10082": None, + "customfield_10000": '{pullrequest={dataType=pullrequest, state=DECLINED, stateCount=1}, json={"cachedValue":{"errors":[],"summary":{"pullrequest":{"overall":{"count":1,"lastUpdated":"2024-03-28T11:08:36.000+0200","stateCount":1,"state":"DECLINED","dataType":"pullrequest","open":False},"byInstanceType":{"GitHub":{"count":1,"name":"GitHub"}}}}},"isStale":True}}', + "customfield_10089": None, + "customfield_10001": None, + "customfield_10002": [], + "customfield_10003": None, + "customfield_10004": None, + "environment": None, + "duedate": None, + }, + }, + { + "expand": "operations,versionedRepresentations,editmeta,changelog,customfield_10010.requestTypePractice,renderedFields", + "id": "17214", + "self": "https://testapp.atlassian.net/rest/api/3/issue/17214", + "key": "PROJ1-6651", + "fields": { + "statuscategorychangedate": "2024-05-06T12:16:59.087+0300", + "customfield_10075": None, + "customfield_10076": None, + "fixVersions": [], + "customfield_10110": None, + "customfield_10077": None, + "customfield_10078": None, + "customfield_10079": None, + "resolution": { + "self": "https://testapp.atlassian.net/rest/api/3/resolution/10000", + "id": "10000", + "description": "Work has been completed on this issue.", + "name": "Done", + }, + "customfield_10104": [], + "customfield_10105": None, + "customfield_10106": None, + "lastViewed": None, + "customfield_10100": None, + "priority": { + "self": "https://testapp.atlassian.net/rest/api/3/priority/2", + "iconUrl": "https://testapp.atlassian.net/images/icons/priorities/high.svg", + "name": "High", + "id": "2", + }, + "customfield_10101": None, + "customfield_10102": None, + "labels": ["port-auth-service"], + "customfield_10103": None, + "timeestimate": None, + "aggregatetimeoriginalestimate": None, + "versions": [], + "issuelinks": [], + "assignee": { + "self": "https://testapp.atlassian.net/rest/api/3/user?accountId=712020%3A77418b87-f1a0-434b-954c-466b807b4364", + "accountId": "712020:77418b87-f1a0-434b-954c-466b807b4364", + "emailAddress": "user@testapp.com", + "avatarUrls": { + "48x48": "https://secure.gravatar.com/avatar/a53b3c50458fd0d6252e3715c5777132?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FAA-5.png", + "24x24": "https://secure.gravatar.com/avatar/a53b3c50458fd0d6252e3715c5777132?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FAA-5.png", + "16x16": "https://secure.gravatar.com/avatar/a53b3c50458fd0d6252e3715c5777132?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FAA-5.png", + "32x32": "https://secure.gravatar.com/avatar/a53b3c50458fd0d6252e3715c5777132?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FAA-5.png", + }, + "displayName": "User User", + "active": True, + "timeZone": "Los Angeles/America", + "accountType": "atlassian", + }, + "status": { + "self": "https://testapp.atlassian.net/rest/api/3/status/10001", + "description": "", + "iconUrl": "https://testapp.atlassian.net/", + "name": "Done", + "id": "10001", + "statusCategory": { + "self": "https://testapp.atlassian.net/rest/api/3/statuscategory/3", + "id": 3, + "key": "done", + "colorName": "green", + "name": "Done", + }, + }, + "components": [], + "customfield_10050": None, + "customfield_10051": None, + "customfield_10052": None, + "customfield_10053": None, + "customfield_10054": None, + "customfield_10056": None, + "customfield_10057": None, + "customfield_10058": None, + "aggregatetimeestimate": None, + "creator": { + "self": "https://testapp.atlassian.net/rest/api/3/user?accountId=712020%3A05acda87-42da-44d8-b21e-f71a508e5d11", + "accountId": "712020:05acda87-42da-44d8-b21e-f71a508e5d11", + "emailAddress": "milkee@testapp.com", + "avatarUrls": { + "48x48": "https://secure.gravatar.com/avatar/0d5d34ceb820d324d69046a1b2f51dc0?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FIC-3.png", + "24x24": "https://secure.gravatar.com/avatar/0d5d34ceb820d324d69046a1b2f51dc0?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FIC-3.png", + "16x16": "https://secure.gravatar.com/avatar/0d5d34ceb820d324d69046a1b2f51dc0?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FIC-3.png", + "32x32": "https://secure.gravatar.com/avatar/0d5d34ceb820d324d69046a1b2f51dc0?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FIC-3.png", + }, + "displayName": "Milkee Coffie", + "active": True, + "timeZone": "Los Angeles/America", + "accountType": "atlassian", + }, + "subtasks": [], + "reporter": { + "self": "https://testapp.atlassian.net/rest/api/3/user?accountId=712020%3A05acda87-42da-44d8-b21e-f71a508e5d11", + "accountId": "712020:05acda87-42da-44d8-b21e-f71a508e5d11", + "emailAddress": "milkee@testapp.com", + "avatarUrls": { + "48x48": "https://secure.gravatar.com/avatar/0d5d34ceb820d324d69046a1b2f51dc0?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FIC-3.png", + "24x24": "https://secure.gravatar.com/avatar/0d5d34ceb820d324d69046a1b2f51dc0?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FIC-3.png", + "16x16": "https://secure.gravatar.com/avatar/0d5d34ceb820d324d69046a1b2f51dc0?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FIC-3.png", + "32x32": "https://secure.gravatar.com/avatar/0d5d34ceb820d324d69046a1b2f51dc0?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FIC-3.png", + }, + "displayName": "Milkee Coffie", + "active": True, + "timeZone": "Los Angeles/America", + "accountType": "atlassian", + }, + "aggregateprogress": {"progress": 0, "total": 0}, + "progress": {"progress": 0, "total": 0}, + "votes": { + "self": "https://testapp.atlassian.net/rest/api/3/issue/PROJ1-6651/votes", + "votes": 0, + "hasVoted": False, + }, + "issuetype": { + "self": "https://testapp.atlassian.net/rest/api/3/issuetype/10002", + "id": "10002", + "description": "A small, distinct piece of work.", + "iconUrl": "https://testapp.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10318?size=medium", + "name": "Task", + "subtask": False, + "avatarId": 10318, + "hierarchyLevel": 0, + }, + "timespent": None, + "customfield_10030": None, + "project": { + "self": "https://testapp.atlassian.net/rest/api/3/project/10000", + "id": "10000", + "key": "PROJ1", + "name": "Port", + "projectTypeKey": "software", + "simplified": False, + "avatarUrls": { + "48x48": "https://testapp.atlassian.net/rest/api/3/universal_avatar/view/type/project/avatar/10560", + "24x24": "https://testapp.atlassian.net/rest/api/3/universal_avatar/view/type/project/avatar/10560?size=small", + "16x16": "https://testapp.atlassian.net/rest/api/3/universal_avatar/view/type/project/avatar/10560?size=xsmall", + "32x32": "https://testapp.atlassian.net/rest/api/3/universal_avatar/view/type/project/avatar/10560?size=medium", + }, + }, + "customfield_10031": None, + "customfield_10032": None, + "customfield_10033": None, + "aggregatetimespent": None, + "customfield_10029": [], + "resolutiondate": "2024-05-06T12:16:59.059+0300", + "workratio": -1, + "watches": { + "self": "https://testapp.atlassian.net/rest/api/3/issue/PROJ1-6651/watchers", + "watchCount": 2, + "isWatching": False, + }, + "created": "2024-02-13T18:04:00.863+0200", + "customfield_10020": [ + { + "id": 32, + "name": "Sprint 28", + "state": "closed", + "boardId": 1, + "goal": "", + "startDate": "2024-04-02T08:15:07.681Z", + "endDate": "2024-04-22T20:00:00.000Z", + "completeDate": "2024-04-24T07:38:09.546Z", + }, + { + "id": 33, + "name": "Sprint 29", + "state": "closed", + "boardId": 1, + "goal": "", + "startDate": "2024-04-24T07:38:32.991Z", + "endDate": "2024-05-21T20:30:00.000Z", + "completeDate": "2024-05-22T07:34:36.617Z", + }, + { + "id": 30, + "name": "Sprint 27", + "state": "closed", + "boardId": 1, + "goal": "", + "startDate": "2024-03-12T11:21:33.527Z", + "endDate": "2024-04-01T21:30:00.000Z", + "completeDate": "2024-04-02T08:15:37.055Z", + }, + ], + "customfield_10021": None, + "customfield_10022": None, + "customfield_10023": None, + "customfield_10024": None, + "customfield_10025": "3_*:*_1_*:*_297004837_*|*_10000_*:*_1_*:*_498723440_*|*_10002_*:*_1_*:*_6351049998_*|*_10001_*:*_1_*:*_0", + "customfield_10026": None, + "customfield_10016": None, + "customfield_10017": None, + "customfield_10018": { + "hasEpicLinkFieldDependency": False, + "showField": False, + "nonEditableReason": { + "reason": "PLUGIN_LICENSE_ERROR", + "message": "The Parent Link is only available to Jira Premium users.", + }, + }, + "customfield_10019": "0|i00vtn:", + "updated": "2024-05-06T12:16:59.087+0300", + "customfield_10090": None, + "customfield_10092": None, + "customfield_10093": None, + "customfield_10094": None, + "timeoriginalestimate": None, + "customfield_10095": None, + "customfield_10096": None, + "customfield_10097": None, + "description": { + "type": "doc", + "version": 1, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "In Jira, one can add extra metadata to the issue using the labels. We want to write a guide on how to connect a Jira Issue to an existing service blueprint using JQ.", + }, + {"type": "hardBreak"}, + {"type": "hardBreak"}, + { + "type": "mention", + "attrs": { + "id": "6242f78df813eb0069289616", + "text": "@Yonatan Boguslavski", + "accessLevel": "", + }, + }, + {"type": "text", "text": " "}, + ], + } + ], + }, + "customfield_10010": None, + "customfield_10098": None, + "customfield_10099": None, + "customfield_10014": None, + "customfield_10015": None, + "customfield_10005": None, + "customfield_10006": None, + "security": None, + "customfield_10007": None, + "customfield_10008": None, + "customfield_10009": None, + "summary": "Implement feature 5 and 6", + "customfield_10082": None, + "customfield_10000": '{pullrequest={dataType=pullrequest, state=MERGED, stateCount=1}, json={"cachedValue":{"errors":[],"summary":{"pullrequest":{"overall":{"count":1,"lastUpdated":"2024-03-03T14:38:33.000+0200","stateCount":1,"state":"MERGED","dataType":"pullrequest","open":False},"byInstanceType":{"GitHub":{"count":1,"name":"GitHub"}}}}},"isStale":True}}', + "customfield_10089": None, + "customfield_10001": None, + "customfield_10002": [], + "customfield_10003": None, + "customfield_10004": None, + "environment": None, + "duedate": None, + }, + }, + { + "expand": "operations,versionedRepresentations,editmeta,changelog,customfield_10010.requestTypePractice,renderedFields", + "id": "17150", + "self": "https://testapp.atlassian.net/rest/api/3/issue/17150", + "key": "PROJ1-6587", + "fields": { + "statuscategorychangedate": "2024-05-06T12:16:45.919+0300", + "customfield_10075": None, + "customfield_10076": None, + "fixVersions": [], + "customfield_10110": None, + "customfield_10077": None, + "customfield_10078": None, + "customfield_10079": None, + "resolution": { + "self": "https://testapp.atlassian.net/rest/api/3/resolution/10000", + "id": "10000", + "description": "Work has been completed on this issue.", + "name": "Done", + }, + "customfield_10104": [], + "customfield_10105": None, + "customfield_10106": None, + "lastViewed": None, + "customfield_10100": None, + "priority": { + "self": "https://testapp.atlassian.net/rest/api/3/priority/10002", + "iconUrl": "https://testapp.atlassian.net/images/icons/priorities/major.svg", + "name": "Must Have", + "id": "10002", + }, + "customfield_10101": None, + "customfield_10102": None, + "customfield_10103": None, + "labels": [], + "timeestimate": None, + "aggregatetimeoriginalestimate": None, + "versions": [], + "issuelinks": [], + "assignee": { + "self": "https://testapp.atlassian.net/rest/api/3/user?accountId=712020%3A77418b87-f1a0-434b-954c-466b807b4364", + "accountId": "712020:77418b87-f1a0-434b-954c-466b807b4364", + "emailAddress": "user@testapp.com", + "avatarUrls": { + "48x48": "https://secure.gravatar.com/avatar/a53b3c50458fd0d6252e3715c5777132?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FAA-5.png", + "24x24": "https://secure.gravatar.com/avatar/a53b3c50458fd0d6252e3715c5777132?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FAA-5.png", + "16x16": "https://secure.gravatar.com/avatar/a53b3c50458fd0d6252e3715c5777132?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FAA-5.png", + "32x32": "https://secure.gravatar.com/avatar/a53b3c50458fd0d6252e3715c5777132?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FAA-5.png", + }, + "displayName": "User User", + "active": True, + "timeZone": "Los Angeles/America", + "accountType": "atlassian", + }, + "status": { + "self": "https://testapp.atlassian.net/rest/api/3/status/10001", + "description": "", + "iconUrl": "https://testapp.atlassian.net/", + "name": "Done", + "id": "10001", + "statusCategory": { + "self": "https://testapp.atlassian.net/rest/api/3/statuscategory/3", + "id": 3, + "key": "done", + "colorName": "green", + "name": "Done", + }, + }, + "components": [], + "customfield_10050": None, + "customfield_10051": None, + "customfield_10052": None, + "customfield_10053": None, + "customfield_10054": None, + "customfield_10056": None, + "customfield_10057": None, + "customfield_10058": None, + "aggregatetimeestimate": None, + "creator": { + "self": "https://testapp.atlassian.net/rest/api/3/user?accountId=6242f78df813eb0069289616", + "accountId": "6242f78df813eb0069289616", + "emailAddress": "baba@testapp.com", + "avatarUrls": { + "48x48": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/6242f78df813eb0069289616/66327023-6dd5-4ac0-802c-b1cd361eb4d7/48", + "24x24": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/6242f78df813eb0069289616/66327023-6dd5-4ac0-802c-b1cd361eb4d7/24", + "16x16": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/6242f78df813eb0069289616/66327023-6dd5-4ac0-802c-b1cd361eb4d7/16", + "32x32": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/6242f78df813eb0069289616/66327023-6dd5-4ac0-802c-b1cd361eb4d7/32", + }, + "displayName": "User Baba", + "active": True, + "timeZone": "Los Angeles/America", + "accountType": "atlassian", + }, + "subtasks": [], + "reporter": { + "self": "https://testapp.atlassian.net/rest/api/3/user?accountId=6242f78df813eb0069289616", + "accountId": "6242f78df813eb0069289616", + "emailAddress": "baba@testapp.com", + "avatarUrls": { + "48x48": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/6242f78df813eb0069289616/66327023-6dd5-4ac0-802c-b1cd361eb4d7/48", + "24x24": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/6242f78df813eb0069289616/66327023-6dd5-4ac0-802c-b1cd361eb4d7/24", + "16x16": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/6242f78df813eb0069289616/66327023-6dd5-4ac0-802c-b1cd361eb4d7/16", + "32x32": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/6242f78df813eb0069289616/66327023-6dd5-4ac0-802c-b1cd361eb4d7/32", + }, + "displayName": "User Baba", + "active": True, + "timeZone": "Los Angeles/America", + "accountType": "atlassian", + }, + "aggregateprogress": {"progress": 0, "total": 0}, + "progress": {"progress": 0, "total": 0}, + "votes": { + "self": "https://testapp.atlassian.net/rest/api/3/issue/PROJ1-6587/votes", + "votes": 0, + "hasVoted": False, + }, + "issuetype": { + "self": "https://testapp.atlassian.net/rest/api/3/issuetype/10002", + "id": "10002", + "description": "A small, distinct piece of work.", + "iconUrl": "https://testapp.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10318?size=medium", + "name": "Task", + "subtask": False, + "avatarId": 10318, + "hierarchyLevel": 0, + }, + "timespent": None, + "customfield_10030": None, + "project": { + "self": "https://testapp.atlassian.net/rest/api/3/project/10000", + "id": "10000", + "key": "PROJ1", + "name": "Port", + "projectTypeKey": "software", + "simplified": False, + "avatarUrls": { + "48x48": "https://testapp.atlassian.net/rest/api/3/universal_avatar/view/type/project/avatar/10560", + "24x24": "https://testapp.atlassian.net/rest/api/3/universal_avatar/view/type/project/avatar/10560?size=small", + "16x16": "https://testapp.atlassian.net/rest/api/3/universal_avatar/view/type/project/avatar/10560?size=xsmall", + "32x32": "https://testapp.atlassian.net/rest/api/3/universal_avatar/view/type/project/avatar/10560?size=medium", + }, + }, + "customfield_10031": None, + "customfield_10032": None, + "customfield_10033": None, + "aggregatetimespent": None, + "customfield_10029": [], + "resolutiondate": "2024-05-06T12:16:45.912+0300", + "workratio": -1, + "watches": { + "self": "https://testapp.atlassian.net/rest/api/3/issue/PROJ1-6587/watchers", + "watchCount": 1, + "isWatching": False, + }, + "created": "2024-02-08T20:35:52.708+0200", + "customfield_10020": [ + { + "id": 32, + "name": "Sprint 28", + "state": "closed", + "boardId": 1, + "goal": "", + "startDate": "2024-04-02T08:15:07.681Z", + "endDate": "2024-04-22T20:00:00.000Z", + "completeDate": "2024-04-24T07:38:09.546Z", + }, + { + "id": 33, + "name": "Sprint 29", + "state": "closed", + "boardId": 1, + "goal": "", + "startDate": "2024-04-24T07:38:32.991Z", + "endDate": "2024-05-21T20:30:00.000Z", + "completeDate": "2024-05-22T07:34:36.617Z", + }, + { + "id": 30, + "name": "Sprint 27", + "state": "closed", + "boardId": 1, + "goal": "", + "startDate": "2024-03-12T11:21:33.527Z", + "endDate": "2024-04-01T21:30:00.000Z", + "completeDate": "2024-04-02T08:15:37.055Z", + }, + ], + "customfield_10021": None, + "customfield_10022": None, + "customfield_10023": None, + "customfield_10024": None, + "customfield_10025": "3_*:*_2_*:*_134956320_*|*_10000_*:*_1_*:*_3569522086_*|*_10002_*:*_5_*:*_3857627052_*|*_10001_*:*_4_*:*_7547818", + "customfield_10026": None, + "customfield_10016": None, + "customfield_10017": None, + "customfield_10018": { + "hasEpicLinkFieldDependency": False, + "showField": False, + "nonEditableReason": { + "reason": "PLUGIN_LICENSE_ERROR", + "message": "The Parent Link is only available to Jira Premium users.", + }, + }, + "customfield_10019": "0|i00vg3:", + "updated": "2024-05-06T12:16:45.918+0300", + "customfield_10090": None, + "customfield_10092": None, + "customfield_10093": None, + "customfield_10094": None, + "timeoriginalestimate": None, + "customfield_10095": None, + "customfield_10096": None, + "description": {"type": "doc", "version": 1, "content": []}, + "customfield_10097": None, + "customfield_10010": None, + "customfield_10098": None, + "customfield_10099": None, + "customfield_10014": None, + "customfield_10015": None, + "customfield_10005": None, + "customfield_10006": None, + "security": None, + "customfield_10007": None, + "customfield_10008": None, + "customfield_10009": None, + "summary": "Implement feature 7 and 8", + "customfield_10082": None, + "customfield_10000": '{pullrequest={dataType=pullrequest, state=MERGED, stateCount=1}, json={"cachedValue":{"errors":[],"summary":{"pullrequest":{"overall":{"count":1,"lastUpdated":"2024-03-24T09:32:32.000+0200","stateCount":1,"state":"MERGED","dataType":"pullrequest","open":False},"byInstanceType":{"GitHub":{"count":1,"name":"GitHub"}}}}},"isStale":True}}', + "customfield_10089": None, + "customfield_10001": None, + "customfield_10002": [], + "customfield_10003": None, + "customfield_10004": None, + "environment": None, + "duedate": None, + }, + }, +] + +PROJECTS = [ + { + "expand": "description,lead,issueTypes,url,projectKeys,permissions,insight", + "self": "https://testapp.atlassian.net/rest/api/3/project/1000", + "id": "1000", + "key": "PROJ1", + "name": "Project 1", + "avatarUrls": { + "48x48": "https://testapp.atlassian.net/rest/api/3/universal_avatar/view/type/project/avatar", + "24x24": "https://testapp.atlassian.net/rest/api/3/universal_avatar/view/type/project/avatar", + "16x16": "https://testapp.atlassian.net/rest/api/3/universal_avatar/view/type/project/avatar", + "32x32": "https://testapp.atlassian.net/rest/api/3/universal_avatar/view/type/project/avatar", + }, + "projectTypeKey": "software", + "simplified": True, + "style": "next-gen", + "isPrivate": False, + "properties": {}, + "entityId": "691f1ded-56c3-469e-8b54-e720833654bd", + "uuid": "691f1ded-56c3-469e-8b54-e720833654bd", + }, + { + "expand": "description,lead,issueTypes,url,projectKeys,permissions,insight", + "self": "https://testapp.atlassian.net/rest/api/3/project/1001", + "id": "1001", + "key": "PROJ2", + "name": "Project 2", + "avatarUrls": { + "48x48": "https://testapp.atlassian.net/rest/api/3/universal_avatar/view/type/project/avatar/1001", + "24x24": "https://testapp.atlassian.net/rest/api/3/universal_avatar/view/type/project/avatar/1001", + "16x16": "https://testapp.atlassian.net/rest/api/3/universal_avatar/view/type/project/avatar/1001", + "32x32": "https://testapp.atlassian.net/rest/api/3/universal_avatar/view/type/project/avatar/1001", + }, + "projectTypeKey": "software", + "simplified": True, + "style": "next-gen", + "isPrivate": False, + "properties": {}, + "entityId": "8679b078-77d5-496f-a88b-dfcc9521e112", + "uuid": "8679b078-77d5-496f-a88b-dfcc9521e112", + }, + { + "expand": "description,lead,issueTypes,url,projectKeys,permissions,insight", + "self": "https://testapp.atlassian.net/rest/api/3/project/1002", + "id": "1002", + "key": "PROJ3", + "name": "Project 3", + "avatarUrls": { + "48x48": "https://testapp.atlassian.net/rest/api/3/universal_avatar/view/type/project/avatar/1002", + "24x24": "https://testapp.atlassian.net/rest/api/3/universal_avatar/view/type/project/avatar/1002", + "16x16": "https://testapp.atlassian.net/rest/api/3/universal_avatar/view/type/project/avatar/1002", + "32x32": "https://testapp.atlassian.net/rest/api/3/universal_avatar/view/type/project/avatar/1002", + }, + "projectTypeKey": "business", + "simplified": True, + "style": "next-gen", + "isPrivate": False, + "properties": {}, + "entityId": "5998ec47-654e-416b-80ec-d49516a794e5", + "uuid": "5998ec47-654e-416b-80ec-d49516a794e5", + }, + { + "expand": "description,lead,issueTypes,url,projectKeys,permissions,insight", + "self": "https://testapp.atlassian.net/rest/api/3/project/1004", + "id": "1004", + "key": "PROJ4", + "name": "Project 4", + "avatarUrls": { + "48x48": "https://testapp.atlassian.net/rest/api/3/universal_avatar/view/type/project/avatar/1004", + "24x24": "https://testapp.atlassian.net/rest/api/3/universal_avatar/view/type/project/avatar/1004", + "16x16": "https://testapp.atlassian.net/rest/api/3/universal_avatar/view/type/project/avatar/1004", + "32x32": "https://testapp.atlassian.net/rest/api/3/universal_avatar/view/type/project/avatar/1004", + }, + "projectTypeKey": "software", + "simplified": False, + "style": "classic", + "isPrivate": False, + "properties": {}, + }, +] + +PROJECT_WEBHOOK = { + "timestamp": 1730997478952, + # replace this as needed in test cases + "webhookEvent": "project_created", + "project": { + "self": "https://myorg.atlassian.net/rest/api/2/project/10002", + "id": 10002, + "key": "SEC", + "name": "Second", + "avatarUrls": { + "48x48": "https://myorg.atlassian.net/rest/api/2/universal_avatar/view/type/project/avatar/10415", + "24x24": "https://myorg.atlassian.net/rest/api/2/universal_avatar/view/type/project/avatar/10415?size=small", + "16x16": "https://myorg.atlassian.net/rest/api/2/universal_avatar/view/type/project/avatar/10415?size=xsmall", + "32x32": "https://myorg.atlassian.net/rest/api/2/universal_avatar/view/type/project/avatar/10415?size=medium", + }, + "projectLead": { + "self": "https://myorg.atlassian.net/rest/api/2/user?accountId=5fdd28d8332c3a010744da75", + "accountId": "5fdd28d8332c3a010744da75", + "avatarUrls": { + "48x48": "https://secure.gravatar.com/avatar/50f9a774bbe1e46f3a790ae4c077f7c1?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FAA-2.png", + "24x24": "https://secure.gravatar.com/avatar/50f9a774bbe1e46f3a790ae4c077f7c1?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FAA-2.png", + "16x16": "https://secure.gravatar.com/avatar/50f9a774bbe1e46f3a790ae4c077f7c1?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FAA-2.png", + "32x32": "https://secure.gravatar.com/avatar/50f9a774bbe1e46f3a790ae4c077f7c1?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FAA-2.png", + }, + "displayName": "Baba Yetu", + "active": True, + "timeZone": "Africa/Khartoum", + "accountType": "atlassian", + }, + "assigneeType": "admin.assignee.type.unassigned", + }, +} + +ISSUE_WEBHOOK = { + "timestamp": 1730998726980, + # replace this as needed in test cases + "webhookEvent": "jira:issue_updated", + "issue_event_type_name": "issue_assigned", + "user": { + "self": "https://myorg.atlassian.net/rest/api/2/user?accountId=5fdd28d8332c3a010744da75", + "accountId": "5fdd28d8332c3a010744da75", + "avatarUrls": { + "48x48": "https://secure.gravatar.com/avatar/50f9a774bbe1e46f3a790ae4c077f7c1?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FAA-2.png", + "24x24": "https://secure.gravatar.com/avatar/50f9a774bbe1e46f3a790ae4c077f7c1?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FAA-2.png", + "16x16": "https://secure.gravatar.com/avatar/50f9a774bbe1e46f3a790ae4c077f7c1?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FAA-2.png", + "32x32": "https://secure.gravatar.com/avatar/50f9a774bbe1e46f3a790ae4c077f7c1?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FAA-2.png", + }, + "displayName": "Baba Yetu", + "active": True, + "timeZone": "Africa/Khartoum", + "accountType": "atlassian", + }, + "issue": { + "id": "10006", + "self": "https://myorg.atlassian.net/rest/api/2/10006", + "key": "PTP-7", + "fields": { + "statuscategorychangedate": "2024-09-05T02:59:02.700+0100", + "issuetype": { + "self": "https://myorg.atlassian.net/rest/api/2/issuetype/10001", + "id": "10001", + "description": "A small, distinct piece of work.", + "iconUrl": "https://myorg.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10318?size=medium", + "name": "Task", + "subtask": False, + "avatarId": 10318, + "entityId": "2bf26269-9628-45a4-a659-6f457adc9c43", + "hierarchyLevel": 0, + }, + "timespent": None, + "customfield_10030": None, + "project": { + "self": "https://myorg.atlassian.net/rest/api/2/project/10000", + "id": "10000", + "key": "PTP", + "name": "Port Test Project", + "projectTypeKey": "business", + "simplified": True, + "avatarUrls": { + "48x48": "https://myorg.atlassian.net/rest/api/2/universal_avatar/view/type/project/avatar/10416", + "24x24": "https://myorg.atlassian.net/rest/api/2/universal_avatar/view/type/project/avatar/10416?size=small", + "16x16": "https://myorg.atlassian.net/rest/api/2/universal_avatar/view/type/project/avatar/10416?size=xsmall", + "32x32": "https://myorg.atlassian.net/rest/api/2/universal_avatar/view/type/project/avatar/10416?size=medium", + }, + }, + "customfield_10032": None, + "fixVersions": [], + "customfield_10033": None, + "aggregatetimespent": None, + "resolution": None, + "customfield_10035": None, + "customfield_10036": None, + "customfield_10037": None, + "customfield_10027": None, + "customfield_10028": None, + "customfield_10029": None, + "resolutiondate": None, + "workratio": -1, + "issuerestriction": {"issuerestrictions": {}, "shouldDisplay": True}, + "watches": { + "self": "https://myorg.atlassian.net/rest/api/2/issue/PTP-7/watchers", + "watchCount": 1, + "isWatching": True, + }, + "lastViewed": "2024-11-07T13:48:43.893+0100", + "created": "2024-09-05T02:59:02.314+0100", + "customfield_10020": None, + "customfield_10021": None, + "customfield_10022": None, + "priority": { + "self": "https://myorg.atlassian.net/rest/api/2/priority/3", + "iconUrl": "https://myorg.atlassian.net/images/icons/priorities/medium.svg", + "name": "Medium", + "id": "3", + }, + "customfield_10023": None, + "customfield_10024": None, + "customfield_10025": None, + "customfield_10026": None, + "labels": [], + "customfield_10016": None, + "customfield_10017": None, + "customfield_10018": { + "hasEpicLinkFieldDependency": False, + "showField": False, + "NoneditableReason": { + "reason": "PLUGIN_LICENSE_ERROR", + "message": "The Parent Link is only available to Jira Premium users.", + }, + }, + "customfield_10019": "0|i00007:", + "timeestimate": None, + "aggregatetimeoriginalestimate": None, + "versions": [], + "issuelinks": [], + "assignee": { + "self": "https://myorg.atlassian.net/rest/api/2/user?accountId=5fdd28d8332c3a010744da75", + "accountId": "5fdd28d8332c3a010744da75", + "avatarUrls": { + "48x48": "https://secure.gravatar.com/avatar/50f9a774bbe1e46f3a790ae4c077f7c1?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FAA-2.png", + "24x24": "https://secure.gravatar.com/avatar/50f9a774bbe1e46f3a790ae4c077f7c1?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FAA-2.png", + "16x16": "https://secure.gravatar.com/avatar/50f9a774bbe1e46f3a790ae4c077f7c1?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FAA-2.png", + "32x32": "https://secure.gravatar.com/avatar/50f9a774bbe1e46f3a790ae4c077f7c1?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FAA-2.png", + }, + "displayName": "Baba Yetu", + "active": True, + "timeZone": "Africa/Khartoum", + "accountType": "atlassian", + }, + "updated": "2024-11-07T17:58:46.980+0100", + "status": { + "self": "https://myorg.atlassian.net/rest/api/2/status/10000", + "description": "", + "iconUrl": "https://myorg.atlassian.net/", + "name": "Open", + "id": "10000", + "statusCategory": { + "self": "https://myorg.atlassian.net/rest/api/2/statuscategory/2", + "id": 2, + "key": "new", + "colorName": "blue-gray", + "name": "New", + }, + }, + "components": [], + "timeoriginalestimate": None, + "description": None, + "customfield_10010": None, + "customfield_10014": None, + "timetracking": {}, + "customfield_10015": None, + "customfield_10005": None, + "customfield_10006": None, + "security": None, + "customfield_10007": None, + "customfield_10008": None, + "customfield_10009": None, + "aggregatetimeestimate": None, + "attachment": [], + "summary": "API documentation not displaying on backend home page", + "creator": { + "self": "https://myorg.atlassian.net/rest/api/2/user?accountId=5fdd28d8332c3a010744da75", + "accountId": "5fdd28d8332c3a010744da75", + "avatarUrls": { + "48x48": "https://secure.gravatar.com/avatar/50f9a774bbe1e46f3a790ae4c077f7c1?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FAA-2.png", + "24x24": "https://secure.gravatar.com/avatar/50f9a774bbe1e46f3a790ae4c077f7c1?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FAA-2.png", + "16x16": "https://secure.gravatar.com/avatar/50f9a774bbe1e46f3a790ae4c077f7c1?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FAA-2.png", + "32x32": "https://secure.gravatar.com/avatar/50f9a774bbe1e46f3a790ae4c077f7c1?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FAA-2.png", + }, + "displayName": "Baba Yetu", + "active": True, + "timeZone": "Africa/Khartoum", + "accountType": "atlassian", + }, + "subtasks": [], + "reporter": { + "self": "https://myorg.atlassian.net/rest/api/2/user?accountId=5fdd28d8332c3a010744da75", + "accountId": "5fdd28d8332c3a010744da75", + "avatarUrls": { + "48x48": "https://secure.gravatar.com/avatar/50f9a774bbe1e46f3a790ae4c077f7c1?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FAA-2.png", + "24x24": "https://secure.gravatar.com/avatar/50f9a774bbe1e46f3a790ae4c077f7c1?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FAA-2.png", + "16x16": "https://secure.gravatar.com/avatar/50f9a774bbe1e46f3a790ae4c077f7c1?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FAA-2.png", + "32x32": "https://secure.gravatar.com/avatar/50f9a774bbe1e46f3a790ae4c077f7c1?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FAA-2.png", + }, + "displayName": "Baba Yetu", + "active": True, + "timeZone": "Africa/Khartoum", + "accountType": "atlassian", + }, + "aggregateprogress": {"progress": 0, "total": 0}, + "customfield_10001": None, + "customfield_10002": [], + "customfield_10003": None, + "customfield_10004": None, + "environment": None, + "duedate": None, + "progress": {"progress": 0, "total": 0}, + "votes": { + "self": "https://myorg.atlassian.net/rest/api/2/issue/PTP-7/votes", + "votes": 0, + "hasVoted": False, + }, + }, + }, + "changelog": { + "id": "10016", + "items": [ + { + "field": "assignee", + "fieldtype": "jira", + "fieldId": "assignee", + "from": None, + "fromString": None, + "to": "5fdd28d8332c3a010744da75", + "toString": "Baba Yetu", + "tmpFromAccountId": None, + "tmpToAccountId": "5fdd28d8332c3a010744da75", + } + ], + }, +} diff --git a/integrations/jira/tests/test_client.py b/integrations/jira/tests/test_client.py new file mode 100644 index 0000000000..48676df3eb --- /dev/null +++ b/integrations/jira/tests/test_client.py @@ -0,0 +1,157 @@ +from typing import Any, TypedDict +from unittest.mock import MagicMock + +import httpx +import pytest + +from client import JiraClient + + +class HttpxResponses(TypedDict): + status_code: int + json: dict[str, Any] + + +class MockHttpxClient: + def __init__(self, responses: list[HttpxResponses] = []) -> None: + self.responses = [ + httpx.Response( + status_code=response["status_code"], + json=response["json"], + request=httpx.Request("GET", "https://myorg.atlassian.net"), + ) + for response in responses + ] + self._current_response_index = 0 + + async def get(self, *args: tuple[Any], **kwargs: dict[str, Any]) -> httpx.Response: + if self._current_response_index >= len(self.responses): + raise httpx.HTTPError("No more responses") + + response = self.responses[self._current_response_index] + self._current_response_index += 1 + return response + + +@pytest.mark.parametrize( + "kwargs,expected_result", + [ + ({}, {"maxResults": 50, "startAt": 0}), + ( + { + "maxResults": 100, + "startAt": 50, + }, + { + "maxResults": 100, + "startAt": 50, + }, + ), + ], +) +def test_base_required_params_are_generated_correctly( + mock_ocean_context: Any, kwargs: dict[str, int], expected_result: dict[str, int] +) -> None: + assert JiraClient._generate_base_req_params(**kwargs) == expected_result + + +@pytest.mark.asyncio +async def test_make_paginated_request_will_hit_api_till_no_response_left( + mock_ocean_context: Any, +) -> None: + client = JiraClient( + "https://myorg.atlassian.net", + "mail@email.com", + "token", + ) + + # we can't monkeypatch the client because it's an instance attribute + client.client = MockHttpxClient( # type: ignore + responses=[ + { + "status_code": 200, + "json": { + "isLast": False, + "values": [ + {"id": 1}, + {"id": 2}, + ], + "startAt": 0, + "maxResults": 2, + }, + }, + { + "status_code": 200, + "json": { + "isLast": True, + "values": [ + {"id": 3}, + {"id": 4}, + ], + "startAt": 2, + "maxResults": 2, + }, + }, + ] + ) + + count = 0 + + async for _ in client._make_paginated_request("https://myorg.atlassian.net"): + count += 1 + + assert count == 2 + + +@pytest.mark.asyncio +async def test_get_all_projects_will_compose_correct_url( + mock_ocean_context: Any, monkeypatch: Any +) -> None: + mock_paginated_request = MagicMock() + mock_paginated_request.__aiter__.return_value = () + + monkeypatch.setattr(JiraClient, "_make_paginated_request", mock_paginated_request) + + client = JiraClient( + "https://myorg.atlassian.net", + "mail@email.com", + "token", + ) + + async for _ in client.get_all_projects(): + pass + + client._make_paginated_request.assert_called_with( # type: ignore + "https://myorg.atlassian.net/rest/api/3/project/search" + ) + + +@pytest.mark.asyncio +async def test_get_all_issues_will_compose_correct_url( + mock_ocean_context: Any, monkeypatch: Any +) -> None: + mock_paginated_request = MagicMock() + mock_paginated_request.__aiter__.return_value = () + + monkeypatch.setattr(JiraClient, "_make_paginated_request", mock_paginated_request) + + client = JiraClient("https://myorg.atlassian.net", "mail.email.com", "token") + + async for _ in client.get_all_issues( + { + "jql": "project = TEST", + "fields": "summary", + } + ): + pass + + # we can't assert the exact call because one of the params is a lambda + # function, so we'll just check the url + assert client._make_paginated_request.call_args_list[0][0] == ( # type: ignore + "https://myorg.atlassian.net/rest/api/3/search", + ) + + assert client._make_paginated_request.call_args_list[0][1]["params"] == { # type: ignore + "jql": "project = TEST", + "fields": "summary", + } diff --git a/integrations/jira/tests/test_sample.py b/integrations/jira/tests/test_sample.py deleted file mode 100644 index dc80e299c8..0000000000 --- a/integrations/jira/tests/test_sample.py +++ /dev/null @@ -1,2 +0,0 @@ -def test_example() -> None: - assert 1 == 1 diff --git a/integrations/jira/tests/test_sync.py b/integrations/jira/tests/test_sync.py new file mode 100644 index 0000000000..09e6fb8d3b --- /dev/null +++ b/integrations/jira/tests/test_sync.py @@ -0,0 +1,41 @@ +# from typing import Any +# from unittest.mock import AsyncMock + +# import pytest +# from port_ocean import Ocean +# from port_ocean.tests.helpers.ocean_app import ( +# get_integation_resource_configs, +# get_raw_result_on_integration_sync_resource_config, +# ) + +# from client import JiraClient + + +# @pytest.mark.asyncio +# async def test_full_sync_produces_correct_response_from_api( +# monkeypatch: Any, +# ocean_app: Ocean, +# integration_path: str, +# issues: list[dict[str, Any]], +# projects: list[dict[str, Any]], +# mock_ocean_context: Any, +# ) -> None: +# projects_mock = AsyncMock() +# projects_mock.return_value = projects +# issues_mock = AsyncMock() +# issues_mock.return_value = issues + +# monkeypatch.setattr(JiraClient, "get_all_projects", projects_mock) +# monkeypatch.setattr(JiraClient, "get_all_issues", issues_mock) +# resource_configs = get_integation_resource_configs(integration_path) +# for resource_config in resource_configs: +# print(resource_config) +# results = await get_raw_result_on_integration_sync_resource_config( +# ocean_app, resource_config +# ) +# assert len(results) > 0 +# entities, errors = results +# assert len(errors) == 0 +# # the factories have 4 entities each +# # all in one batch +# assert len(list(entities)) == 1 diff --git a/integrations/jira/tests/test_webhook.py b/integrations/jira/tests/test_webhook.py new file mode 100644 index 0000000000..5c64741864 --- /dev/null +++ b/integrations/jira/tests/test_webhook.py @@ -0,0 +1,112 @@ +# from typing import Any +# from unittest.mock import AsyncMock + +# from port_ocean import Ocean +# from port_ocean.context.ocean import ocean +# from starlette.testclient import TestClient + +# from client import JiraClient + +# from .fixtures import ISSUE_WEBHOOK, PROJECT_WEBHOOK + +# ENDPOINT = "/integration/webhook" + + +# def test_project_creation_event_will_create_project( +# monkeypatch: Any, ocean_app: Ocean +# ) -> None: +# client = TestClient(ocean_app) +# project: dict[str, Any] = {**PROJECT_WEBHOOK, "webhookEvent": "project_created"} +# get_single_project_mock = AsyncMock() +# get_single_project_mock.return_value = project["project"] +# register_raw_mock = AsyncMock() +# monkeypatch.setattr(JiraClient, "get_single_project", get_single_project_mock) +# monkeypatch.setattr(ocean, "register_raw", register_raw_mock) + +# response = client.post(ENDPOINT, json=project) + +# assert response.status_code == 200 +# assert response.json() == {"ok": True} +# assert get_single_project_mock.called +# assert register_raw_mock.called +# register_raw_mock.assert_called_once_with("project", [project["project"]]) +# get_single_project_mock.assert_called_once_with(project["project"]["key"]) + + +# def test_project_deletion_event_will_delete_project( +# monkeypatch: Any, ocean_app: Ocean +# ) -> None: +# client = TestClient(ocean_app) +# project = {**PROJECT_WEBHOOK, "webhookEvent": "project_deleted"} +# unregister_raw_mock = AsyncMock() +# monkeypatch.setattr(ocean, "unregister_raw", unregister_raw_mock) + +# response = client.post(ENDPOINT, json=project) + +# assert response.status_code == 200 +# assert response.json() == {"ok": True} +# assert unregister_raw_mock.called +# unregister_raw_mock.assert_called_once_with("project", [project["project"]]) + + +# def test_issue_creation_event_will_create_issue( +# monkeypatch: Any, ocean_app: Ocean +# ) -> None: +# client = TestClient(ocean_app) +# issue: dict[str, Any] = {**ISSUE_WEBHOOK, "webhookEvent": "jira:issue_created"} +# get_all_issues_mock = AsyncMock() +# get_all_issues_mock.__aiter__.return_value = issue["issue"] +# register_raw_mock = AsyncMock() +# monkeypatch.setattr(JiraClient, "get_all_issues", get_all_issues_mock) +# monkeypatch.setattr(ocean, "register_raw", register_raw_mock) + +# response = client.post(ENDPOINT, json=issue) + +# assert response.status_code == 200 +# assert response.json() == {"ok": True} +# assert get_all_issues_mock.called +# assert register_raw_mock.called +# register_raw_mock.assert_called_once_with("issue", [issue["issue"]]) +# get_all_issues_mock.assert_called_once_with( +# {"jql": f"key = {issue['issue']['key']}", "fields": "*all"} +# ) + + +# def test_issue_deletion_event_will_delete_issue( +# monkeypatch: Any, ocean_app: Ocean +# ) -> None: +# client = TestClient(ocean_app) +# issue = {**ISSUE_WEBHOOK, "webhookEvent": "jira:issue_deleted"} +# unregister_raw_mock = AsyncMock() +# monkeypatch.setattr(ocean, "unregister_raw", unregister_raw_mock) + +# response = client.post(ENDPOINT, json=issue) + +# assert response.status_code == 200 +# assert response.json() == {"ok": True} +# assert unregister_raw_mock.called +# unregister_raw_mock.assert_called_once_with("issue", [issue["issue"]]) + + +# def test_issue_update_event_will_update_issue( +# monkeypatch: Any, ocean_app: Ocean +# ) -> None: +# client = TestClient(ocean_app) +# issue: dict[str, Any] = {**ISSUE_WEBHOOK, "webhookEvent": "jira:issue_updated"} +# get_single_issue_mock = AsyncMock() +# get_single_issue_mock.return_value = issue["issue"] +# register_raw_mock = AsyncMock() + +# monkeypatch.setattr(JiraClient, "get_single_issue", get_single_issue_mock) +# monkeypatch.setattr(ocean, "register_raw", register_raw_mock) + +# response = client.post(ENDPOINT, json=issue) + +# assert response.status_code == 200 +# assert response.json() == {"ok": True} +# assert get_single_issue_mock.called +# assert register_raw_mock.called +# register_raw_mock.assert_called_once_with("issue", [issue["issue"]]) +# get_single_issue_mock.assert_called_once_with( +# {"jql": f"key = {issue['issue']['key']}", "fields": "*all"} +# )