diff --git a/integrations/jira/.port/resources/blueprints.json b/integrations/jira/.port/resources/blueprints.json
index f298bc0c3b..99454ca856 100644
--- a/integrations/jira/.port/resources/blueprints.json
+++ b/integrations/jira/.port/resources/blueprints.json
@@ -21,6 +21,47 @@
},
"calculationProperties": {}
},
+ {
+ "identifier": "jiraTeam",
+ "title": "Jira Team",
+ "icon": "Users",
+ "description": "A team within the organization",
+ "schema": {
+ "properties": {
+ "organizationId": {
+ "title": "Organization ID",
+ "type": "string",
+ "description": "Unique identifier for the parent organization"
+ },
+ "teamType": {
+ "title": "Team Type",
+ "type": "string",
+ "description": "Type of team (e.g., MEMBER_INVITE)",
+ "enum": [
+ "MEMBER_INVITE",
+ "OPEN"
+ ]
+ },
+ "description": {
+ "title": "Description",
+ "type": "string",
+ "description": "Team description"
+ }
+ }
+ },
+ "relations": {
+ "members": {
+ "target": "jiraUser",
+ "title": "Users",
+ "description": "The Jira users belonging to this team",
+ "required": false,
+ "many": true
+ }
+ },
+ "mirrorProperties": {},
+ "calculationProperties": {},
+ "aggregationProperties": {}
+ },
{
"identifier": "jiraUser",
"title": "Jira User",
@@ -34,11 +75,6 @@
"format": "email",
"description": "User's email address"
},
- "displayName": {
- "title": "Display Name",
- "type": "string",
- "description": "User's full name as displayed in Jira"
- },
"active": {
"title": "Active Status",
"type": "boolean",
@@ -67,10 +103,10 @@
}
}
},
+ "relations": {},
"mirrorProperties": {},
"calculationProperties": {},
- "aggregationProperties": {},
- "relations": {}
+ "aggregationProperties": {}
},
{
"identifier": "jiraIssue",
@@ -182,4 +218,4 @@
}
}
}
-]
+]
\ No newline at end of file
diff --git a/integrations/jira/.port/resources/port-app-config.yaml b/integrations/jira/.port/resources/port-app-config.yaml
index 8d97ee2432..018512676f 100644
--- a/integrations/jira/.port/resources/port-app-config.yaml
+++ b/integrations/jira/.port/resources/port-app-config.yaml
@@ -25,13 +25,29 @@ resources:
blueprint: '"jiraUser"'
properties:
emailAddress: .emailAddress
- displayName: .displayName
active: .active
accountType: .accountType
timeZone: .timeZone
locale: .locale
avatarUrl: .avatarUrls["48x48"]
+ - kind: team
+ selector:
+ query: "true"
+ includeMembers: true
+ port:
+ entity:
+ mappings:
+ identifier: .teamId
+ title: .displayName
+ blueprint: '"jiraTeam"'
+ properties:
+ organizationId: .organizationId
+ teamType: .teamType
+ description: .description
+ relations:
+ members: if .__members != null then .__members | map(.accountId) else [] end
+
- kind: issue
selector:
query: "true"
@@ -59,3 +75,4 @@ resources:
subtasks: .fields.subtasks | map(.key)
assignee: .fields.assignee.accountId // ""
reporter: .fields.reporter.accountId
+
diff --git a/integrations/jira/.port/spec.yaml b/integrations/jira/.port/spec.yaml
index 8de4d14f35..89ba418311 100644
--- a/integrations/jira/.port/spec.yaml
+++ b/integrations/jira/.port/spec.yaml
@@ -8,6 +8,7 @@ features:
resources:
- kind: project
- kind: issue
+ - kind: team
- kind: user
configurations:
- name: appHost
@@ -28,6 +29,11 @@ configurations:
type: string
description: You can configure the user token on the Atlassian account page
sensitive: true
+ - name: atlassianOrganizationId
+ required: false
+ type: string
+ description: To sync teams and team members your Atlassian Organization ID is required . Read How to find your Atlassian Organization ID
+ sensitive: false
saas:
enabled: true
oauthConfiguration:
diff --git a/integrations/jira/CHANGELOG.md b/integrations/jira/CHANGELOG.md
index ea40cb5cc1..bf37e2183c 100644
--- a/integrations/jira/CHANGELOG.md
+++ b/integrations/jira/CHANGELOG.md
@@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## 0.2.20 (2025-1-13)
+
+
+### Improvements
+
+- Added support to sync Jira teams to Port
## 0.2.19 (2025-01-12)
@@ -974,4 +980,4 @@ v## 0.1.0 (2023-08-10)
### Features
-- Added Jira integration with support for projects and issues (PORT-4410)
+- Added Jira integration with support for projects and issues (PORT-4410)
\ No newline at end of file
diff --git a/integrations/jira/jira/client.py b/integrations/jira/jira/client.py
index 8cf1510f21..d866ac4afa 100644
--- a/integrations/jira/jira/client.py
+++ b/integrations/jira/jira/client.py
@@ -1,5 +1,7 @@
+import asyncio
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
@@ -8,6 +10,7 @@
PAGE_SIZE = 50
WEBHOOK_NAME = "Port-Ocean-Events-Webhook"
+MAX_CONCURRENT_REQUESTS = 10
WEBHOOK_EVENTS = [
"jira:issue_created",
@@ -51,46 +54,111 @@ def __init__(self, jira_url: str, jira_email: str, jira_token: str) -> None:
self.jira_api_auth = BasicAuth(self.jira_email, self.jira_token)
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)
+
+ async def _send_api_request(
+ self,
+ method: str,
+ url: str,
+ params: dict[str, Any] | None = None,
+ json: dict[str, Any] | None = None,
+ headers: dict[str, str] | None = None,
+ ) -> Any:
+ try:
+ async with self.semaphore:
+ response = await self.client.request(
+ method=method, url=url, params=params, json=json, headers=headers
+ )
+ response.raise_for_status()
+ return response.json()
+ except httpx.HTTPStatusError as e:
+ logger.error(
+ f"Jira API request failed with status {e.response.status_code}: {method} {url}"
+ )
+ raise
+ except httpx.RequestError as e:
+ logger.error(f"Failed to connect to Jira API: {method} {url} - {str(e)}")
+ raise
+
+ async def _get_paginated_data(
+ self,
+ url: str,
+ extract_key: str | None = None,
+ initial_params: dict[str, Any] | None = None,
+ ) -> AsyncGenerator[list[dict[str, Any]], None]:
+ params = initial_params or {}
+ params |= self._generate_base_req_params()
+
+ start_at = 0
+ while True:
+ params["startAt"] = start_at
+ response_data = await self._send_api_request("GET", url, params=params)
+ items = response_data.get(extract_key, []) if extract_key else response_data
+
+ if not items:
+ break
+
+ yield items
+
+ start_at += len(items)
+
+ if "total" in response_data and start_at >= response_data["total"]:
+ break
+
+ async def _get_cursor_paginated_data(
+ self,
+ url: str,
+ method: str,
+ extract_key: str,
+ initial_params: dict[str, Any] | None = None,
+ page_size: int = PAGE_SIZE,
+ cursor_param: str = "cursor",
+ ) -> AsyncGenerator[list[dict[str, Any]], None]:
+ params = initial_params or {}
+ cursor = params.get(cursor_param)
+
+ while True:
+ if cursor:
+ params[cursor_param] = cursor
+
+ response_data = await self._send_api_request(method, url, params=params)
+
+ items = response_data.get(extract_key, [])
+ if not items:
+ break
+
+ yield items
+
+ page_info = response_data.get("pageInfo", {})
+ cursor = page_info.get("endCursor")
+
+ if not page_info.get("hasNextPage", False):
+ break
@staticmethod
def _generate_base_req_params(
- maxResults: int = 0, startAt: int = 0
+ maxResults: int = PAGE_SIZE, 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 _get_webhooks(self) -> list[dict[str, Any]]:
+ return await self._send_api_request("GET", url=self.webhooks_url)
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()
+ webhooks = await self._get_webhooks()
- for webhook in webhook_check:
- if webhook["url"] == webhook_target_app_host:
+ for webhook in webhooks:
+ if webhook.get("url") == webhook_target_app_host:
logger.info("Ocean real time reporting webhook already exists")
return
@@ -100,101 +168,95 @@ async def create_events_webhook(self, app_host: str) -> None:
"events": WEBHOOK_EVENTS,
}
- webhook_create_response = await self.client.post(
- f"{self.webhooks_url}", json=body
- )
- webhook_create_response.raise_for_status()
+ await self._send_api_request("POST", self.webhooks_url, json=body)
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}"
+ return await self._send_api_request(
+ "GET", f"{self.api_url}/project/{project_key}"
)
- project_response.raise_for_status()
- return project_response.json()
async def get_paginated_projects(
- self, params: dict[str, Any] = {}
+ self, params: dict[str, Any] | None = None
) -> AsyncGenerator[list[dict[str, Any]], None]:
logger.info("Getting projects from Jira")
-
- params.update(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 for projects in self._get_paginated_data(
+ f"{self.api_url}/project/search", "values", initial_params=params
+ ):
+ yield projects
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()
+ return await self._send_api_request("GET", f"{self.api_url}/issue/{issue_key}")
async def get_paginated_issues(
- self, params: dict[str, Any] = {}
+ self, params: dict[str, Any] | None = None
) -> AsyncGenerator[list[dict[str, Any]], None]:
logger.info("Getting issues from Jira")
- params.update(self._generate_base_req_params())
- total_issues = (await self._get_paginated_issues(params))["total"]
+ params = params or {}
+ if "jql" in params:
+ logger.info(f"Using JQL filter: {params['jql']}")
- if total_issues == 0:
- logger.warning(
- "Issue query returned 0 issues, did you provide the correct Jira API credentials and JQL query?"
- )
+ async for issues in self._get_paginated_data(
+ f"{self.api_url}/search", "issues", initial_params=params
+ ):
+ yield issues
- 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_single_user(self, account_id: str) -> dict[str, Any]:
+ return await self._send_api_request(
+ "GET", f"{self.api_url}/user", params={"accountId": account_id}
+ )
- async def get_paginated_users(
- self,
- ) -> AsyncGenerator[list[dict[str, Any]], None]:
+ async def get_paginated_users(self) -> AsyncGenerator[list[dict[str, Any]], None]:
logger.info("Getting users from Jira")
+ async for users in self._get_paginated_data(f"{self.api_url}/users/search"):
+ yield users
- 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
+ async def get_paginated_teams(
+ self, org_id: str
+ ) -> AsyncGenerator[list[dict[str, Any]], None]:
+ logger.info("Getting teams from Jira")
- logger.info(
- f"Retrieved users: {len(user_response_list)} "
- f"(Position: {params['startAt']}/{total_users})"
- )
+ base_url = f"{self.teams_base_url}/{org_id}/teams"
- yield user_response_list
- params["startAt"] += PAGE_SIZE
+ async for teams in self._get_cursor_paginated_data(
+ url=base_url, method="GET", extract_key="entities", cursor_param="cursor"
+ ):
+ yield teams
- 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()
+ async def get_paginated_team_members(
+ self, team_id: str, org_id: str, page_size: int = PAGE_SIZE
+ ) -> AsyncGenerator[list[dict[str, Any]], None]:
+ url = f"{self.teams_base_url}/{org_id}/teams/{team_id}/members"
+
+ async for members in self._get_cursor_paginated_data(
+ url,
+ method="POST",
+ extract_key="results",
+ initial_params={"first": page_size},
+ cursor_param="after",
+ ):
+ yield members
+
+ async def fetch_team_members(
+ self, team_id: str, org_id: str
+ ) -> list[dict[str, Any]]:
+ members = []
+ async for batch in self.get_paginated_team_members(team_id, org_id):
+ members.extend(batch)
+ return members
+
+ async def enrich_teams_with_members(
+ self, teams: list[dict[str, Any]], org_id: str
+ ) -> list[dict[str, Any]]:
+ logger.debug(f"Fetching members for {len(teams)} teams")
+
+ team_tasks = [self.fetch_team_members(team["teamId"], org_id) for team in teams]
+ results = await asyncio.gather(*team_tasks)
+
+ total_members = sum(len(members) for members in results)
+ logger.info(f"Retrieved {total_members} members across {len(teams)} teams")
+
+ for team, members in zip(teams, results):
+ team["__members"] = members
+
+ return teams
diff --git a/integrations/jira/jira/overrides.py b/integrations/jira/jira/overrides.py
index 31b9807299..6563c7dbbd 100644
--- a/integrations/jira/jira/overrides.py
+++ b/integrations/jira/jira/overrides.py
@@ -1,4 +1,4 @@
-from typing import Annotated, Literal, Union
+from typing import Literal
from port_ocean.core.handlers.port_app_config.models import (
PortAppConfig,
@@ -8,6 +8,19 @@
from pydantic import Field
+class TeamSelector(Selector):
+ include_members: bool = Field(
+ alias="includeMembers",
+ default=False,
+ description="Whether to include the members of the team, defaults to false",
+ )
+
+
+class TeamResourceConfig(ResourceConfig):
+ kind: Literal["team"]
+ selector: TeamSelector
+
+
class JiraIssueSelector(Selector):
jql: str | None = None
fields: str | None = Field(
@@ -33,11 +46,10 @@ class JiraProjectResourceConfig(ResourceConfig):
kind: Literal["project"]
-JiraResourcesConfig = Annotated[
- Union[JiraIssueConfig, JiraProjectResourceConfig],
- Field(discriminator="kind"),
-]
-
-
class JiraPortAppConfig(PortAppConfig):
- resources: list[JiraIssueConfig | JiraProjectResourceConfig | ResourceConfig]
+ resources: list[
+ TeamResourceConfig
+ | JiraIssueConfig
+ | JiraProjectResourceConfig
+ | ResourceConfig
+ ]
diff --git a/integrations/jira/main.py b/integrations/jira/main.py
index b07814cb70..2c1ba8ae5b 100644
--- a/integrations/jira/main.py
+++ b/integrations/jira/main.py
@@ -13,6 +13,7 @@
JiraIssueSelector,
JiraPortAppConfig,
JiraProjectResourceConfig,
+ TeamResourceConfig,
)
@@ -20,6 +21,7 @@ class ObjectKind(StrEnum):
PROJECT = "project"
ISSUE = "issue"
USER = "user"
+ TEAM = "team"
def create_jira_client() -> JiraClient:
@@ -41,11 +43,8 @@ async def setup_application() -> None:
)
return
- jira_client = create_jira_client()
-
- await jira_client.create_events_webhook(
- logic_settings["app_host"],
- )
+ client = create_jira_client()
+ await client.create_events_webhook(app_host)
@ocean.on_resync(ObjectKind.PROJECT)
@@ -81,13 +80,32 @@ async def on_resync_issues(kind: str) -> ASYNC_GENERATOR_RESYNC_TYPE:
yield issues
+@ocean.on_resync(ObjectKind.TEAM)
+async def on_resync_teams(kind: str) -> ASYNC_GENERATOR_RESYNC_TYPE:
+ client = create_jira_client()
+ org_id = ocean.integration_config.get("atlassian_organization_id")
+
+ if not org_id:
+ logger.warning(
+ "Atlassian organization ID wasn't specified, unable to sync teams, skipping"
+ )
+ return
+
+ selector = cast(TeamResourceConfig, event.resource_config).selector
+ async for teams in client.get_paginated_teams(org_id):
+ logger.info(f"Received team batch with {len(teams)} teams")
+ if selector.include_members:
+ teams = await client.enrich_teams_with_members(teams, org_id)
+ yield teams
+
+
@ocean.on_resync(ObjectKind.USER)
async def on_resync_users(kind: str) -> ASYNC_GENERATOR_RESYNC_TYPE:
client = create_jira_client()
- async for users in client.get_paginated_users():
- logger.info(f"Received users batch with {len(users)} users")
- yield users
+ async for users_batch in client.get_paginated_users():
+ logger.info(f"Received users batch with {len(users_batch)} users")
+ yield users_batch
@ocean.router.post("/webhook")
@@ -155,15 +173,16 @@ async def handle_webhook_request(data: dict[str, Any]) -> dict[str, Any]:
return {"ok": True}
case _:
- logger.error(f"Unknown webhook event type: {webhook_event}")
+ logger.warning(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"}
+ error_msg = f"Failed to retrieve {kind}"
+ logger.warning(error_msg)
+ return {"ok": False, "error": error_msg}
logger.debug(f"Retrieved {kind} item: {item}")
diff --git a/integrations/jira/pyproject.toml b/integrations/jira/pyproject.toml
index e77571a4c4..355db88b77 100644
--- a/integrations/jira/pyproject.toml
+++ b/integrations/jira/pyproject.toml
@@ -1,6 +1,6 @@
[tool.poetry]
name = "jira"
-version = "0.2.19"
+version = "0.2.20"
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
new file mode 100644
index 0000000000..d743498273
--- /dev/null
+++ b/integrations/jira/tests/test_client.py
@@ -0,0 +1,292 @@
+from typing import Any
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import BasicAuth, Request, Response
+from port_ocean.context.ocean import initialize_port_ocean_context
+from port_ocean.exceptions.context import PortOceanContextAlreadyInitializedError
+
+from jira.client import PAGE_SIZE, WEBHOOK_EVENTS, JiraClient
+
+
+@pytest.fixture(autouse=True)
+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",
+ "atlassian_organisation_id": "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
+def mock_jira_client() -> JiraClient:
+ """Fixture to initialize JiraClient with mock parameters."""
+ return JiraClient(
+ jira_url="https://example.atlassian.net",
+ jira_email="test@example.com",
+ jira_token="test_token",
+ )
+
+
+@pytest.mark.asyncio
+async def test_client_initialization(mock_jira_client: JiraClient) -> None:
+ """Test the correct initialization of JiraClient."""
+ assert mock_jira_client.jira_rest_url == "https://example.atlassian.net/rest"
+ assert isinstance(mock_jira_client.jira_api_auth, BasicAuth)
+
+
+@pytest.mark.asyncio
+async def test_send_api_request_success(mock_jira_client: JiraClient) -> None:
+ """Test successful API requests."""
+ with patch.object(
+ mock_jira_client.client, "request", new_callable=AsyncMock
+ ) as mock_request:
+ mock_request.return_value = Response(
+ 200, request=Request("GET", "http://example.com"), json={"key": "value"}
+ )
+ response = await mock_jira_client._send_api_request("GET", "http://example.com")
+ assert response["key"] == "value"
+
+
+@pytest.mark.asyncio
+async def test_send_api_request_failure(mock_jira_client: JiraClient) -> None:
+ """Test API request raising exceptions."""
+ with patch.object(
+ mock_jira_client.client, "request", new_callable=AsyncMock
+ ) as mock_request:
+ mock_request.return_value = Response(
+ 404, request=Request("GET", "http://example.com")
+ )
+ with pytest.raises(Exception):
+ await mock_jira_client._send_api_request("GET", "http://example.com")
+
+
+@pytest.mark.asyncio
+async def test_get_single_project(mock_jira_client: JiraClient) -> None:
+ """Test get_single_project method"""
+ project_data: dict[str, Any] = {"key": "TEST", "name": "Test Project"}
+
+ with patch.object(
+ mock_jira_client, "_send_api_request", new_callable=AsyncMock
+ ) as mock_request:
+ mock_request.return_value = project_data
+ result = await mock_jira_client.get_single_project("TEST")
+
+ mock_request.assert_called_once_with(
+ "GET", f"{mock_jira_client.api_url}/project/TEST"
+ )
+ assert result == project_data
+
+
+@pytest.mark.asyncio
+async def test_get_paginated_projects(mock_jira_client: JiraClient) -> None:
+ """Test get_paginated_projects method"""
+ projects_data: dict[str, Any] = {
+ "values": [{"key": "PROJ1"}, {"key": "PROJ2"}],
+ "total": 2,
+ }
+
+ with patch.object(
+ mock_jira_client, "_send_api_request", new_callable=AsyncMock
+ ) as mock_request:
+ mock_request.side_effect = [
+ projects_data,
+ {"values": []}, # Empty response to end pagination
+ ]
+
+ projects: list[dict[str, Any]] = []
+ async for project_batch in mock_jira_client.get_paginated_projects():
+ projects.extend(project_batch)
+
+ assert len(projects) == 2
+ assert projects == projects_data["values"]
+ mock_request.assert_called_with(
+ "GET",
+ f"{mock_jira_client.api_url}/project/search",
+ params={"maxResults": PAGE_SIZE, "startAt": 0},
+ )
+
+
+@pytest.mark.asyncio
+async def test_get_single_issue(mock_jira_client: JiraClient) -> None:
+ """Test get_single_issue method"""
+ issue_data: dict[str, Any] = {"key": "TEST-1", "fields": {"summary": "Test Issue"}}
+
+ with patch.object(
+ mock_jira_client, "_send_api_request", new_callable=AsyncMock
+ ) as mock_request:
+ mock_request.return_value = issue_data
+ result = await mock_jira_client.get_single_issue("TEST-1")
+
+ mock_request.assert_called_once_with(
+ "GET", f"{mock_jira_client.api_url}/issue/TEST-1"
+ )
+ assert result == issue_data
+
+
+@pytest.mark.asyncio
+async def test_get_paginated_issues(mock_jira_client: JiraClient) -> None:
+ """Test get_paginated_issues with params including JQL filtering"""
+
+ # Mock response data
+ issues_data = {"issues": [{"key": "TEST-1"}, {"key": "TEST-2"}], "total": 2}
+
+ with patch.object(
+ mock_jira_client, "_send_api_request", new_callable=AsyncMock
+ ) as mock_request:
+ mock_request.side_effect = [issues_data, {"issues": []}]
+
+ issues = []
+ async for issue_batch in mock_jira_client.get_paginated_issues(
+ params={"jql": "project = TEST"}
+ ):
+ issues.extend(issue_batch)
+
+ assert len(issues) == 2
+ assert issues == issues_data["issues"]
+
+ # Verify params were passed correctly
+ mock_request.assert_called_with(
+ "GET",
+ f"{mock_jira_client.api_url}/search",
+ params={
+ "jql": "project = TEST",
+ "maxResults": PAGE_SIZE,
+ "startAt": 0,
+ },
+ )
+
+
+@pytest.mark.asyncio
+async def test_get_single_user(mock_jira_client: JiraClient) -> None:
+ """Test get_single_user method"""
+ user_data: dict[str, Any] = {
+ "accountId": "test123",
+ "emailAddress": "test@example.com",
+ }
+
+ with patch.object(
+ mock_jira_client, "_send_api_request", new_callable=AsyncMock
+ ) as mock_request:
+ mock_request.return_value = user_data
+ result = await mock_jira_client.get_single_user("test123")
+
+ mock_request.assert_called_once_with(
+ "GET", f"{mock_jira_client.api_url}/user", params={"accountId": "test123"}
+ )
+ assert result == user_data
+
+
+@pytest.mark.asyncio
+async def test_get_paginated_users(mock_jira_client: JiraClient) -> None:
+ """Test get_paginated_users method"""
+ users_data: list[dict[str, Any]] = [{"accountId": "user1"}, {"accountId": "user2"}]
+
+ with patch.object(
+ mock_jira_client, "_send_api_request", new_callable=AsyncMock
+ ) as mock_request:
+ mock_request.side_effect = [users_data, []] # Empty response to end pagination
+
+ users: list[dict[str, Any]] = []
+ async for user_batch in mock_jira_client.get_paginated_users():
+ users.extend(user_batch)
+
+ assert len(users) == 2
+ assert users == users_data
+
+
+@pytest.mark.asyncio
+async def test_get_paginated_teams(mock_jira_client: JiraClient) -> None:
+ """Test get_paginated_teams method"""
+ # Mock data
+ teams_data: dict[str, Any] = {
+ "entities": [
+ {"teamId": "team1", "name": "Team 1"},
+ {"teamId": "team2", "name": "Team 2"},
+ ],
+ "cursor": None,
+ }
+
+ with patch.object(
+ mock_jira_client, "_send_api_request", new_callable=AsyncMock
+ ) as mock_request:
+ mock_request.side_effect = [
+ teams_data,
+ {"entities": []}, # Empty response to end pagination
+ ]
+
+ teams: list[dict[str, Any]] = []
+ async for team_batch in mock_jira_client.get_paginated_teams("test_org_id"):
+ teams.extend(team_batch)
+
+ assert len(teams) == 2
+ assert teams == teams_data["entities"]
+
+
+@pytest.mark.asyncio
+async def test_get_paginated_team_members(mock_jira_client: JiraClient) -> None:
+ """Test get_paginated_team_members with example API response format"""
+ page1_response = {
+ "results": [{"accountId": "user1"}, {"accountId": "user2"}],
+ "pageInfo": {"endCursor": "cursor1", "hasNextPage": True},
+ }
+ page2_response = {
+ "results": [{"accountId": "user3"}],
+ "pageInfo": {"endCursor": "cursor2", "hasNextPage": False},
+ }
+
+ with patch.object(
+ mock_jira_client, "_send_api_request", new_callable=AsyncMock
+ ) as mock_request:
+ mock_request.side_effect = [page1_response, page2_response]
+
+ members: list[dict[str, Any]] = []
+ async for member_batch in mock_jira_client.get_paginated_team_members(
+ "team1", "test-org"
+ ):
+ members.extend(member_batch)
+
+ assert len(members) == 3
+
+
+@pytest.mark.asyncio
+async def test_create_events_webhook(mock_jira_client: JiraClient) -> None:
+ """Test create_events_webhook method"""
+ app_host = "https://example.com"
+ webhook_url = f"{app_host}/integration/webhook"
+
+ # Test when webhook doesn't exist
+ with patch.object(
+ mock_jira_client, "_send_api_request", new_callable=AsyncMock
+ ) as mock_request:
+ mock_request.side_effect = [
+ [], # No existing webhooks
+ {"id": "new_webhook"}, # Creation response
+ ]
+
+ await mock_jira_client.create_events_webhook(app_host)
+
+ # Verify webhook creation call
+ create_call = mock_request.call_args_list[1]
+ assert create_call[0][0] == "POST"
+ assert create_call[0][1] == mock_jira_client.webhooks_url
+ assert create_call[1]["json"]["url"] == webhook_url
+ assert create_call[1]["json"]["events"] == WEBHOOK_EVENTS
+
+ # Test when webhook already exists
+ with patch.object(
+ mock_jira_client, "_send_api_request", new_callable=AsyncMock
+ ) as mock_request:
+ mock_request.return_value = [{"url": webhook_url}]
+
+ await mock_jira_client.create_events_webhook(app_host)
+ mock_request.assert_called_once() # Only checks for existence