From 2f929d9a2780fbecdd480fbf3e8e350890d8a3ba Mon Sep 17 00:00:00 2001 From: shariff-6 Date: Thu, 5 Dec 2024 11:22:39 +0300 Subject: [PATCH] [Integration][Jira] Enhance Jira Sync with Users, Mapping Issues To Users (#1196) # Description What -This PR - Adds support for synchronizing Jira users to Port - Relate Jira users to Jira issues Why - Enhance jira integration How - Added a new `jiraUser` blueprint in `blueprints.json` with user properties: - Updated `client.py` to include `_get_paginated_users()` method: - Implements pagination for user retrieval - Handles cases like zero users - Logs retrieval progress - Modified `main.py` to add a new `on_resync_users()` handler: ## Type of change Please leave one option from the following and delete the rest: - [ ] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] New Integration (non-breaking change which adds a new integration) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Non-breaking change (fix of existing functionality that will not change current behavior) - [ ] Documentation (added/updated documentation)

All tests should be run against the port production environment(using a testing org).

### Core testing checklist - [ ] Integration able to create all default resources from scratch - [ ] Resync finishes successfully - [ ] Resync able to create entities - [ ] Resync able to update entities - [ ] Resync able to detect and delete entities - [ ] Scheduled resync able to abort existing resync and start a new one - [ ] Tested with at least 2 integrations from scratch - [ ] Tested with Kafka and Polling event listeners - [ ] Tested deletion of entities that don't pass the selector ### Integration testing checklist - [ ] Integration able to create all default resources from scratch - [ ] Resync able to create entities - [ ] Resync able to update entities - [ ] Resync able to detect and delete entities - [ ] Resync finishes successfully - [ ] If new resource kind is added or updated in the integration, add example raw data, mapping and expected result to the `examples` folder in the integration directory. - [ ] If resource kind is updated, run the integration with the example data and check if the expected result is achieved - [ ] If new resource kind is added or updated, validate that live-events for that resource are working as expected - [ ] Docs PR link [here](#) ### Preflight checklist - [ ] Handled rate limiting - [ ] Handled pagination - [ ] Implemented the code in async - [ ] Support Multi account ## Screenshots ![jiraIssue](https://github.com/user-attachments/assets/6e482101-44b8-410e-8081-8d7f0903d6e8) ![jiraUser](https://github.com/user-attachments/assets/199bca61-a45c-4d64-bb47-ec8d6f0ed381) ## API Documentation Provide links to the API documentation used for this integration. --------- Co-authored-by: PagesCoffy Co-authored-by: Tom Tankilevitch <59158507+Tankilevitch@users.noreply.github.com> --- .../jira/.port/resources/blueprints.json | 77 ++++++++++++++++--- .../jira/.port/resources/port-app-config.yaml | 24 +++++- integrations/jira/.port/spec.yaml | 1 + integrations/jira/CHANGELOG.md | 6 ++ integrations/jira/jira/client.py | 47 +++++++++++ integrations/jira/main.py | 69 ++++++++++++++--- integrations/jira/pyproject.toml | 2 +- 7 files changed, 201 insertions(+), 25 deletions(-) diff --git a/integrations/jira/.port/resources/blueprints.json b/integrations/jira/.port/resources/blueprints.json index 308f499df5..9a58db66ce 100644 --- a/integrations/jira/.port/resources/blueprints.json +++ b/integrations/jira/.port/resources/blueprints.json @@ -16,6 +16,57 @@ }, "calculationProperties": {} }, + { + "identifier": "jiraUser", + "title": "Jira User", + "icon": "User", + "description": "A Jira user account", + "schema": { + "properties": { + "emailAddress": { + "title": "Email", + "type": "string", + "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", + "description": "Whether the user account is active" + }, + "accountType": { + "title": "Account Type", + "type": "string", + "description": "Type of Jira account (e.g., atlassian, customer)" + }, + "timeZone": { + "title": "Time Zone", + "type": "string", + "description": "User's configured time zone" + }, + "locale": { + "title": "Locale", + "type": "string", + "description": "User's configured locale" + }, + "avatarUrl": { + "title": "Avatar URL", + "type": "string", + "format": "url", + "description": "URL for user's 48x48 avatar image" + } + } + }, + "mirrorProperties": {}, + "calculationProperties": {}, + "aggregationProperties": {}, + "relations": {} + }, { "identifier": "jiraIssue", "title": "Jira Issue", @@ -43,18 +94,6 @@ "type": "array", "description": "The components related to this issue" }, - "assignee": { - "title": "Assignee", - "type": "string", - "format": "user", - "description": "The user assigned to the issue" - }, - "reporter": { - "title": "Reporter", - "type": "string", - "description": "The user that reported to the issue", - "format": "user" - }, "creator": { "title": "Creator", "type": "string", @@ -102,6 +141,8 @@ "type": "number" } }, + "mirrorProperties": {}, + "aggregationProperties": {}, "relations": { "project": { "target": "jiraProject", @@ -121,6 +162,18 @@ "title": "Subtasks", "required": false, "many": true + }, + "assignee": { + "target": "jiraUser", + "title": "Assignee", + "required": false, + "many": false + }, + "reporter": { + "target": "jiraUser", + "title": "Reporter", + "required": false, + "many": false } } } diff --git a/integrations/jira/.port/resources/port-app-config.yaml b/integrations/jira/.port/resources/port-app-config.yaml index 04ab5dde6d..f83db206be 100644 --- a/integrations/jira/.port/resources/port-app-config.yaml +++ b/integrations/jira/.port/resources/port-app-config.yaml @@ -12,6 +12,25 @@ resources: blueprint: '"jiraProject"' properties: url: (.self | split("/") | .[:3] | join("/")) + "/projects/" + .key + + - kind: user + selector: + query: "true" + port: + entity: + mappings: + identifier: .accountId + title: .displayName + blueprint: '"jiraUser"' + properties: + emailAddress: .emailAddress + displayName: .displayName + active: .active + accountType: .accountType + timeZone: .timeZone + locale: .locale + avatarUrl: .avatarUrls["48x48"] + - kind: issue selector: query: "true" @@ -27,8 +46,6 @@ resources: status: .fields.status.name issueType: .fields.issuetype.name components: .fields.components - assignee: .fields.assignee.emailAddress - reporter: .fields.reporter.emailAddress creator: .fields.creator.emailAddress priority: .fields.priority.id labels: .fields.labels @@ -39,3 +56,6 @@ resources: project: .fields.project.key parentIssue: .fields.parent.key 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 d931051a80..b211fd01a6 100644 --- a/integrations/jira/.port/spec.yaml +++ b/integrations/jira/.port/spec.yaml @@ -7,6 +7,7 @@ features: resources: - kind: project - kind: issue + - kind: user configurations: - name: appHost required: false diff --git a/integrations/jira/CHANGELOG.md b/integrations/jira/CHANGELOG.md index 59d0b85ebd..eea710cbf3 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.1 (2024-12-05) + + +### Improvements + +- Added support to sync Jira users to Port and created relevant relations to jira issues assignee and reporter ## 0.2.0 (2024-12-04) diff --git a/integrations/jira/jira/client.py b/integrations/jira/jira/client.py index 089e23e17f..6aa8f6df4a 100644 --- a/integrations/jira/jira/client.py +++ b/integrations/jira/jira/client.py @@ -23,6 +23,9 @@ "project_restored_deleted", "project_archived", "project_restored_archived", + "user_created", + "user_updated", + "user_deleted", ] @@ -78,6 +81,11 @@ async def _get_paginated_issues(self, params: dict[str, Any]) -> dict[str, Any]: 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}") @@ -160,3 +168,42 @@ async def get_paginated_issues(self) -> AsyncGenerator[list[dict[str, Any]], Non 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/main.py b/integrations/jira/main.py index 0b439e0989..600072f78d 100644 --- a/integrations/jira/main.py +++ b/integrations/jira/main.py @@ -10,6 +10,7 @@ class ObjectKind(StrEnum): PROJECT = "project" ISSUE = "issue" + USER = "user" async def setup_application() -> None: @@ -59,6 +60,19 @@ async def on_resync_issues(kind: str) -> ASYNC_GENERATOR_RESYNC_TYPE: yield issues +@ocean.on_resync(ObjectKind.USER) +async def on_resync_users(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"], + ) + + async for users in client.get_paginated_users(): + logger.info(f"Received users 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( @@ -66,16 +80,51 @@ async def handle_webhook_request(data: dict[str, Any]) -> dict[str, Any]: ocean.integration_config["atlassian_user_email"], ocean.integration_config["atlassian_user_token"], ) - logger.info(f'Received webhook event of type: {data.get("webhookEvent")}') - if "project" in data: - logger.info(f'Received webhook event for project: {data["project"]["key"]}') - project = await client.get_single_project(data["project"]["key"]) - await ocean.register_raw(ObjectKind.PROJECT, [project]) - elif "issue" in data: - logger.info(f'Received webhook event for issue: {data["issue"]["key"]}') - issue = await client.get_single_issue(data["issue"]["key"]) - await ocean.register_raw(ObjectKind.ISSUE, [issue]) - logger.info("Webhook event processed") + + 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") return {"ok": True} diff --git a/integrations/jira/pyproject.toml b/integrations/jira/pyproject.toml index 2937caa041..9f78f81021 100644 --- a/integrations/jira/pyproject.toml +++ b/integrations/jira/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "jira" -version = "0.2.0" +version = "0.2.1" description = "Integration to bring information from Jira into Port" authors = ["Mor Paz "]