From 068cd170abaadb9ed30f1fba7983074ef9cc5aa4 Mon Sep 17 00:00:00 2001 From: shariff-6 Date: Mon, 13 Jan 2025 14:20:41 +0300 Subject: [PATCH] [Integration][Jira] Adds teams kind (#1227) --- .../jira/.port/resources/blueprints.json | 52 +++- .../jira/.port/resources/port-app-config.yaml | 19 +- integrations/jira/.port/spec.yaml | 6 + integrations/jira/CHANGELOG.md | 8 +- integrations/jira/jira/client.py | 258 ++++++++++------ integrations/jira/jira/overrides.py | 28 +- integrations/jira/main.py | 41 ++- integrations/jira/pyproject.toml | 2 +- integrations/jira/tests/test_client.py | 292 ++++++++++++++++++ 9 files changed, 578 insertions(+), 128 deletions(-) create mode 100644 integrations/jira/tests/test_client.py 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