From a39b48d88b4f25c701b4e5a71099d855ed4e0254 Mon Sep 17 00:00:00 2001 From: pieterck Date: Wed, 20 Mar 2024 23:16:42 +0700 Subject: [PATCH] integrations: Add ClickUp integration. Creates an incoming webhook integration for ClickUp. The main use case is getting notifications when new ClickUp items such as task, list, folder, space, goals are created, updated or deleted. Fixes zulip#26529. --- .../integrations/bot_avatars/clickup.png | Bin 0 -> 3503 bytes static/images/integrations/clickup/001.png | Bin 0 -> 28278 bytes static/images/integrations/logos/clickup.svg | 1 + zerver/lib/integrations.py | 2 + zerver/webhooks/clickup/__init__.py | 39 ++ zerver/webhooks/clickup/api_endpoints.py | 118 +++++ .../clickup/callback_fixtures/get_folder.json | 14 + .../clickup/callback_fixtures/get_goal.json | 33 ++ .../clickup/callback_fixtures/get_list.json | 49 ++ .../clickup/callback_fixtures/get_space.json | 52 ++ .../clickup/callback_fixtures/get_task.json | 63 +++ zerver/webhooks/clickup/doc.md | 61 +++ .../clickup/fixtures/folder_created.json | 5 + .../clickup/fixtures/folder_deleted.json | 5 + .../clickup/fixtures/folder_updated.json | 5 + .../clickup/fixtures/goal_created.json | 5 + .../clickup/fixtures/goal_deleted.json | 5 + .../clickup/fixtures/goal_updated.json | 5 + .../clickup/fixtures/list_created.json | 5 + .../clickup/fixtures/list_deleted.json | 5 + .../clickup/fixtures/list_updated.json | 26 + .../fixtures/payload_with_spammy_field.json | 33 ++ .../clickup/fixtures/space_created.json | 5 + .../clickup/fixtures/space_deleted.json | 5 + .../clickup/fixtures/space_updated.json | 5 + .../clickup/fixtures/task_created.json | 57 ++ .../clickup/fixtures/task_deleted.json | 5 + .../webhooks/clickup/fixtures/task_moved.json | 52 ++ .../clickup/fixtures/task_updated.json | 26 + .../fixtures/task_updated_assignee.json | 32 ++ .../fixtures/task_updated_comment.json | 85 +++ .../fixtures/task_updated_due_date.json | 29 + .../fixtures/task_updated_priority.json | 31 ++ .../clickup/fixtures/task_updated_status.json | 38 ++ .../fixtures/task_updated_time_estimate.json | 38 ++ .../fixtures/task_updated_time_spent.json | 37 ++ zerver/webhooks/clickup/tests.py | 498 ++++++++++++++++++ zerver/webhooks/clickup/view.py | 250 +++++++++ 38 files changed, 1724 insertions(+) create mode 100644 static/images/integrations/bot_avatars/clickup.png create mode 100644 static/images/integrations/clickup/001.png create mode 100644 static/images/integrations/logos/clickup.svg create mode 100644 zerver/webhooks/clickup/__init__.py create mode 100644 zerver/webhooks/clickup/api_endpoints.py create mode 100644 zerver/webhooks/clickup/callback_fixtures/get_folder.json create mode 100644 zerver/webhooks/clickup/callback_fixtures/get_goal.json create mode 100644 zerver/webhooks/clickup/callback_fixtures/get_list.json create mode 100644 zerver/webhooks/clickup/callback_fixtures/get_space.json create mode 100644 zerver/webhooks/clickup/callback_fixtures/get_task.json create mode 100644 zerver/webhooks/clickup/doc.md create mode 100644 zerver/webhooks/clickup/fixtures/folder_created.json create mode 100644 zerver/webhooks/clickup/fixtures/folder_deleted.json create mode 100644 zerver/webhooks/clickup/fixtures/folder_updated.json create mode 100644 zerver/webhooks/clickup/fixtures/goal_created.json create mode 100644 zerver/webhooks/clickup/fixtures/goal_deleted.json create mode 100644 zerver/webhooks/clickup/fixtures/goal_updated.json create mode 100644 zerver/webhooks/clickup/fixtures/list_created.json create mode 100644 zerver/webhooks/clickup/fixtures/list_deleted.json create mode 100644 zerver/webhooks/clickup/fixtures/list_updated.json create mode 100644 zerver/webhooks/clickup/fixtures/payload_with_spammy_field.json create mode 100644 zerver/webhooks/clickup/fixtures/space_created.json create mode 100644 zerver/webhooks/clickup/fixtures/space_deleted.json create mode 100644 zerver/webhooks/clickup/fixtures/space_updated.json create mode 100644 zerver/webhooks/clickup/fixtures/task_created.json create mode 100644 zerver/webhooks/clickup/fixtures/task_deleted.json create mode 100644 zerver/webhooks/clickup/fixtures/task_moved.json create mode 100644 zerver/webhooks/clickup/fixtures/task_updated.json create mode 100644 zerver/webhooks/clickup/fixtures/task_updated_assignee.json create mode 100644 zerver/webhooks/clickup/fixtures/task_updated_comment.json create mode 100644 zerver/webhooks/clickup/fixtures/task_updated_due_date.json create mode 100644 zerver/webhooks/clickup/fixtures/task_updated_priority.json create mode 100644 zerver/webhooks/clickup/fixtures/task_updated_status.json create mode 100644 zerver/webhooks/clickup/fixtures/task_updated_time_estimate.json create mode 100644 zerver/webhooks/clickup/fixtures/task_updated_time_spent.json create mode 100644 zerver/webhooks/clickup/tests.py create mode 100644 zerver/webhooks/clickup/view.py diff --git a/static/images/integrations/bot_avatars/clickup.png b/static/images/integrations/bot_avatars/clickup.png new file mode 100644 index 0000000000000000000000000000000000000000..39197b44d32309074460b48fc98b843dac317221 GIT binary patch literal 3503 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 \ No newline at end of file diff --git a/zerver/lib/integrations.py b/zerver/lib/integrations.py index 15968750d4d392..710e85f60f3c44 100644 --- a/zerver/lib/integrations.py +++ b/zerver/lib/integrations.py @@ -375,6 +375,7 @@ def __init__(self, name: str, *args: Any, **kwargs: Any) -> None: WebhookIntegration("buildbot", ["continuous-integration"], display_name="Buildbot"), WebhookIntegration("canarytoken", ["monitoring"], display_name="Thinkst Canarytokens"), WebhookIntegration("circleci", ["continuous-integration"], display_name="CircleCI"), + WebhookIntegration("clickup", ["project-management"], display_name="ClickUp"), WebhookIntegration("clubhouse", ["project-management"]), WebhookIntegration("codeship", ["continuous-integration", "deployment"]), WebhookIntegration("crashlytics", ["monitoring"]), @@ -735,6 +736,7 @@ def __init__(self, name: str, *args: Any, **kwargs: Any) -> None: ScreenshotConfig("bitbucket_job_completed.json", image_name="001.png"), ScreenshotConfig("github_job_completed.json", image_name="002.png"), ], + "clickup": [ScreenshotConfig("task_moved.json")], "clubhouse": [ScreenshotConfig("story_create.json")], "codeship": [ScreenshotConfig("error_build.json")], "crashlytics": [ScreenshotConfig("issue_message.json")], diff --git a/zerver/webhooks/clickup/__init__.py b/zerver/webhooks/clickup/__init__.py new file mode 100644 index 00000000000000..1851340a070560 --- /dev/null +++ b/zerver/webhooks/clickup/__init__.py @@ -0,0 +1,39 @@ +from enum import Enum +from typing import List + + +class ConstantVariable(Enum): + @classmethod + def as_list(cls) -> List[str]: + return [item.value for item in cls] + + +class EventItemType(ConstantVariable): + TASK: str = "task" + LIST: str = "list" + FOLDER: str = "folder" + GOAL: str = "goal" + SPACE: str = "space" + + +class EventAcion(ConstantVariable): + CREATED: str = "Created" + UPDATED: str = "Updated" + DELETED: str = "Deleted" + + +SIMPLE_FIELDS = ["priority", "status"] + + +class SpecialFields(ConstantVariable): + # Event with unique payload + NAME: str = "name" + ASSIGNEE: str = "assignee_add" + COMMENT: str = "comment" + DUE_DATE: str = "due_date" + MOVED: str = "section_moved" + TIME_ESTIMATE: str = "time_estimate" + TIME_SPENT: str = "time_spent" + + +SPAMMY_FIELDS = ["tag", "tag_removed", "assignee_rem"] diff --git a/zerver/webhooks/clickup/api_endpoints.py b/zerver/webhooks/clickup/api_endpoints.py new file mode 100644 index 00000000000000..2161b78fb95423 --- /dev/null +++ b/zerver/webhooks/clickup/api_endpoints.py @@ -0,0 +1,118 @@ +import re +from typing import Any, Dict, Optional, Union +from urllib.parse import urljoin + +import requests +from django.utils.translation import gettext as _ +from typing_extensions import override + +from zerver.lib.exceptions import ErrorCode, WebhookError +from zerver.lib.outgoing_http import OutgoingSession +from zerver.webhooks.clickup import EventItemType + + +class APIUnavailableCallBackError(WebhookError): + """Intended as an exception for when an integration + couldn't reach external API server when calling back + from Zulip app. + + Exception when callback request has timed out or received + connection error. + """ + + code = ErrorCode.REQUEST_TIMEOUT + http_status_code = 200 + data_fields = ["webhook_name"] + + def __init__(self) -> None: + super().__init__() + + @staticmethod + @override + def msg_format() -> str: + return _("{webhook_name} integration couldn't reach an external API service; ignoring") + + +class BadRequestCallBackError(WebhookError): + """Intended as an exception for when an integration + makes a bad request to external API server. + + Exception when callback request has an invalid format. + """ + + code = ErrorCode.BAD_REQUEST + http_status_code = 200 + data_fields = ["webhook_name", "error_detail"] + + def __init__(self, error_detail: Optional[Union[str, int]]) -> None: + super().__init__() + self.error_detail = error_detail + + @staticmethod + @override + def msg_format() -> str: + return _( + "{webhook_name} integration tries to make a bad outgoing request: {error_detail}; ignoring" + ) + + +class ClickUpSession(OutgoingSession): + def __init__(self, **kwargs: Any) -> None: + super().__init__(role="clickup", timeout=5, **kwargs) # nocoverage + + +def verify_url_path(path: str) -> bool: + parts = path.split("/") + if (len(parts) < 2) or (parts[0] not in EventItemType.as_list()) or (parts[1] == ""): + return False + pattern = r"^[a-zA-Z0-9_-]+$" + match = re.match(pattern, parts[1]) + return match is not None and match.group() == parts[1] + + +def make_clickup_request(path: str, api_key: str) -> Dict[str, Any]: + if verify_url_path(path) is False: + raise BadRequestCallBackError("Invalid path") + headers: Dict[str, str] = { + "Content-Type": "application/json", + "Authorization": api_key, + } + + try: + base_url = "https://api.clickup.com/api/v2/" + api_endpoint = urljoin(base_url, path) + response = ClickUpSession(headers=headers).get( + api_endpoint, + ) + response.raise_for_status() + except (requests.ConnectionError, requests.Timeout): + raise APIUnavailableCallBackError + except requests.HTTPError as e: + raise BadRequestCallBackError(e.response.status_code) + + return response.json() + + +def get_list(api_key: str, list_id: str) -> Dict[str, Any]: + data = make_clickup_request(f"list/{list_id}", api_key) + return data + + +def get_task(api_key: str, task_id: str) -> Dict[str, Any]: + data = make_clickup_request(f"task/{task_id}", api_key) + return data + + +def get_folder(api_key: str, folder_id: str) -> Dict[str, Any]: + data = make_clickup_request(f"folder/{folder_id}", api_key) + return data + + +def get_goal(api_key: str, goal_id: str) -> Dict[str, Any]: + data = make_clickup_request(f"goal/{goal_id}", api_key) + return data + + +def get_space(api_key: str, space_id: str) -> Dict[str, Any]: + data = make_clickup_request(f"space/{space_id}", api_key) + return data diff --git a/zerver/webhooks/clickup/callback_fixtures/get_folder.json b/zerver/webhooks/clickup/callback_fixtures/get_folder.json new file mode 100644 index 00000000000000..f646c9fdac0520 --- /dev/null +++ b/zerver/webhooks/clickup/callback_fixtures/get_folder.json @@ -0,0 +1,14 @@ +{ + "id": "457", + "name": "Lord Foldemort", + "orderindex": 0, + "override_statuses": false, + "hidden": false, + "space": { + "id": "789", + "name": "Space Name", + "access": true + }, + "task_count": "0", + "lists": [] +} diff --git a/zerver/webhooks/clickup/callback_fixtures/get_goal.json b/zerver/webhooks/clickup/callback_fixtures/get_goal.json new file mode 100644 index 00000000000000..733317c1e2be02 --- /dev/null +++ b/zerver/webhooks/clickup/callback_fixtures/get_goal.json @@ -0,0 +1,33 @@ +{ + "goal": { + "id": "e53a033c-900e-462d-a849-4a216b06d930", + "name": "hat-trick", + "team_id": "512", + "date_created": "1568044355026", + "start_date": null, + "due_date": "1568036964079", + "description": "Updated Goal Description", + "private": false, + "archived": false, + "creator": 183, + "color": "#32a852", + "pretty_id": "6", + "multiple_owners": true, + "folder_id": null, + "members": [], + "owners": [ + { + "id": 182, + "username": "Pieter CK", + "email": "kwok.pieter@gmail.com", + "color": "#7b68ee", + "initials": "PK", + "profilePicture": "https://attachments-public.clickup.com/profilePictures/182_abc.jpg" + } + ], + "key_results": [], + "percent_completed": 0, + "history": [], + "pretty_url": "https://app.clickup.com/512/goals/6" + } +} diff --git a/zerver/webhooks/clickup/callback_fixtures/get_list.json b/zerver/webhooks/clickup/callback_fixtures/get_list.json new file mode 100644 index 00000000000000..1fa3309a5f295b --- /dev/null +++ b/zerver/webhooks/clickup/callback_fixtures/get_list.json @@ -0,0 +1,49 @@ +{ + "id": "124", + "name": "Listener", + "orderindex": 1, + "content": "Updated List Content", + "status": { + "status": "red", + "color": "#e50000", + "hide_label": true + }, + "priority": { + "priority": "high", + "color": "#f50000" + }, + "assignee": null, + "due_date": "1567780450202", + "due_date_time": true, + "start_date": null, + "start_date_time": null, + "folder": { + "id": "456", + "name": "Folder Name", + "hidden": false, + "access": true + }, + "space": { + "id": "789", + "name": "Space Name", + "access": true + }, + "inbound_address": "add.task.124.ac725f.31518a6a-05bb-4997-92a6-1dcfe2f527ca@tasks.clickup.com", + "archived": false, + "override_statuses": false, + "statuses": [ + { + "status": "to do", + "orderindex": 0, + "color": "#d3d3d3", + "type": "open" + }, + { + "status": "complete", + "orderindex": 1, + "color": "#6bc950", + "type": "closed" + } + ], + "permission_level": "create" +} diff --git a/zerver/webhooks/clickup/callback_fixtures/get_space.json b/zerver/webhooks/clickup/callback_fixtures/get_space.json new file mode 100644 index 00000000000000..d19af504b23fb4 --- /dev/null +++ b/zerver/webhooks/clickup/callback_fixtures/get_space.json @@ -0,0 +1,52 @@ +{ + "id": "790", + "name": "the Milky Way", + "private": false, + "statuses": [ + { + "status": "to do", + "type": "open", + "orderindex": 0, + "color": "#d3d3d3" + }, + { + "status": "complete", + "type": "closed", + "orderindex": 1, + "color": "#6bc950" + } + ], + "multiple_assignees": false, + "features": { + "due_dates": { + "enabled": false, + "start_date": false, + "remap_due_dates": false, + "remap_closed_due_date": false + }, + "time_tracking": { + "enabled": false + }, + "tags": { + "enabled": false + }, + "time_estimates": { + "enabled": false + }, + "checklists": { + "enabled": true + }, + "custom_fields": { + "enabled": true + }, + "remap_dependencies": { + "enabled": false + }, + "dependency_warning": { + "enabled": false + }, + "portfolios": { + "enabled": false + } + } +} diff --git a/zerver/webhooks/clickup/callback_fixtures/get_task.json b/zerver/webhooks/clickup/callback_fixtures/get_task.json new file mode 100644 index 00000000000000..146db98e6ad868 --- /dev/null +++ b/zerver/webhooks/clickup/callback_fixtures/get_task.json @@ -0,0 +1,63 @@ +{ + "id": "string", + "custom_id": "string", + "custom_item_id": 0, + "name": "Tanswer", + "text_content": "string", + "description": "string", + "status": { + "status": "in progress", + "color": "#d3d3d3", + "orderindex": 1, + "type": "custom" + }, + "orderindex": "string", + "date_created": "string", + "date_updated": "string", + "date_closed": "string", + "creator": { + "id": 183, + "username": "Pieter CK", + "color": "#827718", + "profilePicture": "https://attachments-public.clickup.com/profilePictures/183_abc.jpg" + }, + "assignees": ["string"], + "checklists": ["string"], + "tags": ["string"], + "parent": "string", + "priority": "string", + "due_date": "string", + "start_date": "string", + "time_estimate": "string", + "time_spent": "string", + "custom_fields": [ + { + "id": "string", + "name": "string", + "type": "string", + "type_config": {}, + "date_created": "string", + "hide_from_guests": true, + "value": { + "id": 183, + "username": "Pieter CK", + "email": "kwok.pieter@gmail.com", + "color": "#7b68ee", + "initials": "PK", + "profilePicture": null + }, + "required": true + } + ], + "list": { + "id": "123" + }, + "folder": { + "id": "456" + }, + "space": { + "id": "789" + }, + "url": "https://app.clickup.com/XXXXXXXX/home", + "markdown_description": "string" +} diff --git a/zerver/webhooks/clickup/doc.md b/zerver/webhooks/clickup/doc.md new file mode 100644 index 00000000000000..335895bfdbf21d --- /dev/null +++ b/zerver/webhooks/clickup/doc.md @@ -0,0 +1,61 @@ +# Zulip ClickUp integration + +!!! tip "" + + Note that [Zapier][1] is usually a simpler way to + integrate ClickUp with Zulip. + +Get Zulip notifications for your ClickUp space! + +{start_tabs} + +1. {!create-channel.md!} + +1. {!create-an-incoming-webhook.md!} + +1. {!generate-webhook-url-basic.md!} + +1. Collect your ClickUp **Team ID** by going to your ClickUp home view. + The URL should look like `https://app.clickup.com//home`. + Note down the ``. + +1. Collect your ClickUp **Client ID** and **Client Secret** : + + - Go to and click **Create an App**. + + - You will be prompted for **Redirect URL(s)**, enter the URL for your Zulip organization. + e.g. `{{ zulip_url }}`. + + - Finally, note down the **Client ID** and **Client Secret** + +1. Download [zulip-clickup.py][2]. `Ctrl+s` or `Cmd+s` on that page should + work in most browsers. + +1. Make sure you have a working copy of [Python](https://realpython.com/installing-python/), + it will be needed to run the script. + +1. Run the `zulip-clickup.py` script in a terminal, after replacing the all caps + arguments with the values collected above. + + ``` + python zulip-clickup.py --clickup-team-id TEAM_ID \ + --clickup-client-id CLIENT_ID \ + --clickup-client-secret CLIENT_SECRET + ``` + +1. Follow the instructions in the terminal and keep an eye on your browser as you + will be redirected to a ClickUp authorization page. + +{end_tabs} + +{!congrats.md!} + +![](/static/images/integrations/clickup/001.png) + +### Related documentation + +{!webhooks-url-specification.md!} + +[1]: ./zapier + +[2]: https://raw.githubusercontent.com/zulip/python-zulip-api/main/zulip/integrations/clickup/zulip_clickup.py diff --git a/zerver/webhooks/clickup/fixtures/folder_created.json b/zerver/webhooks/clickup/fixtures/folder_created.json new file mode 100644 index 00000000000000..69ca7103079cc7 --- /dev/null +++ b/zerver/webhooks/clickup/fixtures/folder_created.json @@ -0,0 +1,5 @@ +{ + "event": "folderCreated", + "folder_id": "96772212", + "webhook_id": "7fa3ec74-69a8-4530-a251-8a13730bd204" +} diff --git a/zerver/webhooks/clickup/fixtures/folder_deleted.json b/zerver/webhooks/clickup/fixtures/folder_deleted.json new file mode 100644 index 00000000000000..19671f01194d3c --- /dev/null +++ b/zerver/webhooks/clickup/fixtures/folder_deleted.json @@ -0,0 +1,5 @@ +{ + "event": "folderDeleted", + "folder_id": "96772212", + "webhook_id": "7fa3ec74-69a8-4530-a251-8a13730bd204" +} diff --git a/zerver/webhooks/clickup/fixtures/folder_updated.json b/zerver/webhooks/clickup/fixtures/folder_updated.json new file mode 100644 index 00000000000000..d1b697320b4cfc --- /dev/null +++ b/zerver/webhooks/clickup/fixtures/folder_updated.json @@ -0,0 +1,5 @@ +{ + "event": "folderUpdated", + "folder_id": "96772212", + "webhook_id": "7fa3ec74-69a8-4530-a251-8a13730bd204" +} diff --git a/zerver/webhooks/clickup/fixtures/goal_created.json b/zerver/webhooks/clickup/fixtures/goal_created.json new file mode 100644 index 00000000000000..7f8e5ce8d4a3f7 --- /dev/null +++ b/zerver/webhooks/clickup/fixtures/goal_created.json @@ -0,0 +1,5 @@ +{ + "event": "goalCreated", + "goal_id": "a23e5a3d-74b5-44c2-ab53-917ebe85045a", + "webhook_id": "d5eddb2d-db2b-49e9-87d4-bc6cfbe2313b" +} diff --git a/zerver/webhooks/clickup/fixtures/goal_deleted.json b/zerver/webhooks/clickup/fixtures/goal_deleted.json new file mode 100644 index 00000000000000..626f0e7bd739e0 --- /dev/null +++ b/zerver/webhooks/clickup/fixtures/goal_deleted.json @@ -0,0 +1,5 @@ +{ + "event": "goalDeleted", + "goal_id": "a23e5a3d-74b5-44c2-ab53-917ebe85045a", + "webhook_id": "d5eddb2d-db2b-49e9-87d4-bc6cfbe2313b" +} diff --git a/zerver/webhooks/clickup/fixtures/goal_updated.json b/zerver/webhooks/clickup/fixtures/goal_updated.json new file mode 100644 index 00000000000000..97888fe9cba496 --- /dev/null +++ b/zerver/webhooks/clickup/fixtures/goal_updated.json @@ -0,0 +1,5 @@ +{ + "event": "goalUpdated", + "goal_id": "a23e5a3d-74b5-44c2-ab53-917ebe85045a", + "webhook_id": "d5eddb2d-db2b-49e9-87d4-bc6cfbe2313b" +} diff --git a/zerver/webhooks/clickup/fixtures/list_created.json b/zerver/webhooks/clickup/fixtures/list_created.json new file mode 100644 index 00000000000000..c23716e0ded67f --- /dev/null +++ b/zerver/webhooks/clickup/fixtures/list_created.json @@ -0,0 +1,5 @@ +{ + "event": "listCreated", + "list_id": "162641234", + "webhook_id": "7fa3ec74-69a8-4530-a251-8a13730bd204" +} diff --git a/zerver/webhooks/clickup/fixtures/list_deleted.json b/zerver/webhooks/clickup/fixtures/list_deleted.json new file mode 100644 index 00000000000000..3745ff56f55411 --- /dev/null +++ b/zerver/webhooks/clickup/fixtures/list_deleted.json @@ -0,0 +1,5 @@ +{ + "event": "listDeleted", + "list_id": "162641062", + "webhook_id": "7fa3ec74-69a8-4530-a251-8a13730bd204" +} diff --git a/zerver/webhooks/clickup/fixtures/list_updated.json b/zerver/webhooks/clickup/fixtures/list_updated.json new file mode 100644 index 00000000000000..9eab8aa1cf28bf --- /dev/null +++ b/zerver/webhooks/clickup/fixtures/list_updated.json @@ -0,0 +1,26 @@ +{ + "event": "listUpdated", + "history_items": [ + { + "id": "8a2f82db-7718-4fdb-9493-4849e67f009d", + "type": 6, + "date": "1642740510345", + "field": "name", + "parent_id": "162641285", + "data": {}, + "source": null, + "user": { + "id": 183, + "username": "Pieter", + "email": "kwok.pieter@gmail.com", + "color": "#7b68ee", + "initials": "P", + "profilePicture": null + }, + "before": "webhook payloads 2", + "after": "Webhook payloads round 2" + } + ], + "list_id": "162641285", + "webhook_id": "7fa3ec74-69a8-4530-a251-8a13730bd204" + } diff --git a/zerver/webhooks/clickup/fixtures/payload_with_spammy_field.json b/zerver/webhooks/clickup/fixtures/payload_with_spammy_field.json new file mode 100644 index 00000000000000..10a64e549b94cb --- /dev/null +++ b/zerver/webhooks/clickup/fixtures/payload_with_spammy_field.json @@ -0,0 +1,33 @@ +{ + "event": "taskUpdated", + "history_items": [ + { + "id": "2800797048554170804", + "type": 1, + "date": "1642736652800", + "field": "tag", + "parent_id": "162641062", + "data": {}, + "source": null, + "user": { + "id": 183, + "username": "John", + "email": "john@company.com", + "color": "#7b68ee", + "initials": "J", + "profilePicture": null + }, + "before": null, + "after": [ + { + "name": "def", + "tag_fg": "#FF4081", + "tag_bg": "#FF4081", + "creator": 2770032 + } + ] + } + ], + "task_id": "1vj38vv", + "webhook_id": "7fa3ec74-69a8-4530-a251-8a13730bd204" + } diff --git a/zerver/webhooks/clickup/fixtures/space_created.json b/zerver/webhooks/clickup/fixtures/space_created.json new file mode 100644 index 00000000000000..6d63462fdc5c02 --- /dev/null +++ b/zerver/webhooks/clickup/fixtures/space_created.json @@ -0,0 +1,5 @@ +{ + "event": "spaceCreated", + "space_id": "54650507", + "webhook_id": "7fa3ec74-69a8-4530-a251-8a13730bd204" +} diff --git a/zerver/webhooks/clickup/fixtures/space_deleted.json b/zerver/webhooks/clickup/fixtures/space_deleted.json new file mode 100644 index 00000000000000..1d4ee16bb2098b --- /dev/null +++ b/zerver/webhooks/clickup/fixtures/space_deleted.json @@ -0,0 +1,5 @@ +{ + "event": "spaceDeleted", + "space_id": "54650507", + "webhook_id": "7fa3ec74-69a8-4530-a251-8a13730bd204" +} diff --git a/zerver/webhooks/clickup/fixtures/space_updated.json b/zerver/webhooks/clickup/fixtures/space_updated.json new file mode 100644 index 00000000000000..7f7bcb47d62429 --- /dev/null +++ b/zerver/webhooks/clickup/fixtures/space_updated.json @@ -0,0 +1,5 @@ +{ + "event": "spaceUpdated", + "space_id": "54650507", + "webhook_id": "7fa3ec74-69a8-4530-a251-8a13730bd204" +} diff --git a/zerver/webhooks/clickup/fixtures/task_created.json b/zerver/webhooks/clickup/fixtures/task_created.json new file mode 100644 index 00000000000000..f5fec2db2e7524 --- /dev/null +++ b/zerver/webhooks/clickup/fixtures/task_created.json @@ -0,0 +1,57 @@ +{ + "event": "taskCreated", + "history_items": [ + { + "id": "2800763136717140857", + "type": 1, + "date": "1642734631523", + "field": "status", + "parent_id": "162641062", + "data": { + "status_type": "open" + }, + "source": null, + "user": { + "id": 183, + "username": "Pieter", + "email": "pieterceka123@gmail.com", + "color": "#7b68ee", + "initials": "J", + "profilePicture": null + }, + "before": { + "status": null, + "color": "#000000", + "type": "removed", + "orderindex": -1 + }, + "after": { + "status": "to do", + "color": "#f9d900", + "orderindex": 0, + "type": "open" + } + }, + { + "id": "2800763136700363640", + "type": 1, + "date": "1642734631523", + "field": "task_creation", + "parent_id": "162641062", + "data": {}, + "source": null, + "user": { + "id": 183, + "username": "Pieter", + "email": "kwok.pieter@gmail.com", + "color": "#7b68ee", + "initials": "J", + "profilePicture": null + }, + "before": null, + "after": null + } + ], + "task_id": "1vj37mc", + "webhook_id": "7fa3ec74-69a8-4530-a251-8a13730bd204" +} diff --git a/zerver/webhooks/clickup/fixtures/task_deleted.json b/zerver/webhooks/clickup/fixtures/task_deleted.json new file mode 100644 index 00000000000000..285d14071a44ca --- /dev/null +++ b/zerver/webhooks/clickup/fixtures/task_deleted.json @@ -0,0 +1,5 @@ +{ + "event": "taskDeleted", + "task_id": "1vj37mc", + "webhook_id": "7fa3ec74-69a8-4530-a251-8a13730bd204" +} diff --git a/zerver/webhooks/clickup/fixtures/task_moved.json b/zerver/webhooks/clickup/fixtures/task_moved.json new file mode 100644 index 00000000000000..55df8131215ad1 --- /dev/null +++ b/zerver/webhooks/clickup/fixtures/task_moved.json @@ -0,0 +1,52 @@ +{ + "event": "taskUpdated", + "history_items": [ + { + "id": "2800800851630274181", + "type": 1, + "date": "1642736879339", + "field": "section_moved", + "parent_id": "162641285", + "data": { + "mute_notifications": true + }, + "source": null, + "user": { + "id": 183, + "username": "Pieter", + "email": "kwok.pieter@gmail.com", + "color": "#7b68ee", + "initials": "J", + "profilePicture": null + }, + "before": { + "id": "162641062", + "name": "Webhook payloads", + "category": { + "id": "96771950", + "name": "hidden", + "hidden": true + }, + "project": { + "id": "7002367", + "name": "This is my API Space" + } + }, + "after": { + "id": "162641285", + "name": "webhook payloads 2", + "category": { + "id": "96772049", + "name": "hidden", + "hidden": true + }, + "project": { + "id": "7002367", + "name": "This is my API Space" + } + } + } + ], + "task_id": "1vj38vv", + "webhook_id": "7fa3ec74-69a8-4530-a251-8a13730bd204" + } diff --git a/zerver/webhooks/clickup/fixtures/task_updated.json b/zerver/webhooks/clickup/fixtures/task_updated.json new file mode 100644 index 00000000000000..fa588beae157ae --- /dev/null +++ b/zerver/webhooks/clickup/fixtures/task_updated.json @@ -0,0 +1,26 @@ +{ + "event": "taskUpdated", + "history_items": [ + { + "id": "2800768061568222238", + "type": 1, + "date": "1642734925064", + "field": "content", + "parent_id": "162641062", + "data": {}, + "source": null, + "user": { + "id": 183, + "username": "Pieter", + "email": "pieterceka123@gmail.com", + "color": "#7b68ee", + "initials": "J", + "profilePicture": null + }, + "before": null, + "after": "{\"ops\":[{\"insert\":\"This is a task description update to trigger the \"},{\"insert\":\"\\n\",\"attributes\":{\"block-id\":\"block-24d0457c-908f-412c-8267-da08f8dc93e4\"}}]}" + } + ], + "task_id": "1vj37mc", + "webhook_id": "7fa3ec74-69a8-4530-a251-8a13730bd204" +} diff --git a/zerver/webhooks/clickup/fixtures/task_updated_assignee.json b/zerver/webhooks/clickup/fixtures/task_updated_assignee.json new file mode 100644 index 00000000000000..e710e3aef35fb8 --- /dev/null +++ b/zerver/webhooks/clickup/fixtures/task_updated_assignee.json @@ -0,0 +1,32 @@ +{ + "event": "taskUpdated", + "history_items": [ + { + "id": "2800789353868594308", + "type": 1, + "date": "1642736194135", + "field": "assignee_add", + "parent_id": "162641062", + "data": {}, + "source": null, + "user": { + "id": 183, + "username": "Pieter", + "email": "kwok.pieter@gmail.com", + "color": "#7b68ee", + "initials": "J", + "profilePicture": null + }, + "after": { + "id": 184, + "username": "Sam", + "email": "sam@company.com", + "color": "#7b68ee", + "initials": "S", + "profilePicture": null + } + } + ], + "task_id": "1vj38vv", + "webhook_id": "7fa3ec74-69a8-4530-a251-8a13730bd204" +} diff --git a/zerver/webhooks/clickup/fixtures/task_updated_comment.json b/zerver/webhooks/clickup/fixtures/task_updated_comment.json new file mode 100644 index 00000000000000..a1e443e0c55c16 --- /dev/null +++ b/zerver/webhooks/clickup/fixtures/task_updated_comment.json @@ -0,0 +1,85 @@ +{ + "event": "taskUpdated", + "history_items": [ + { + "id": "2800803631413624919", + "type": 1, + "date": "1642737045116", + "field": "comment", + "parent_id": "162641285", + "data": {}, + "source": null, + "user": { + "id": 183, + "username": "Pieter", + "email": "pieterceka123@gmail.com", + "color": "#7b68ee", + "initials": "J", + "profilePicture": null + }, + "before": null, + "after": "648893191", + "comment": { + "id": "648893191", + "date": "1642737045116", + "parent": "1vj38vv", + "type": 1, + "comment": [ + { + "text": "comment abc1234 56789", + "attributes": {} + }, + { + "text": "\n", + "attributes": { + "block-id": "block-4c8fe54f-7bff-4b7b-92a2-9142068983ea" + } + } + ], + "text_content": "comment abc1234 56789\n", + "x": null, + "y": null, + "image_y": null, + "image_x": null, + "page": null, + "comment_number": null, + "page_id": null, + "page_name": null, + "view_id": null, + "view_name": null, + "team": null, + "user": { + "id": 183, + "username": "Pieter", + "email": "pieterceka123@gmail.com", + "color": "#7b68ee", + "initials": "J", + "profilePicture": null + }, + "new_thread_count": 0, + "new_mentioned_thread_count": 0, + "email_attachments": [], + "threaded_users": [], + "threaded_replies": 0, + "threaded_assignees": 0, + "threaded_assignees_members": [], + "threaded_unresolved_count": 0, + "thread_followers": [ + { + "id": 183, + "username": "Pieter", + "email": "pieterceka123@gmail.com", + "color": "#7b68ee", + "initials": "J", + "profilePicture": null + } + ], + "group_thread_followers": [], + "reactions": [], + "emails": [] + } + } + ], + "task_id": "1vj38vv", + "webhook_id": "7fa3ec74-69a8-4530-a251-8a13730bd204" + } diff --git a/zerver/webhooks/clickup/fixtures/task_updated_due_date.json b/zerver/webhooks/clickup/fixtures/task_updated_due_date.json new file mode 100644 index 00000000000000..b92df8eebb5de4 --- /dev/null +++ b/zerver/webhooks/clickup/fixtures/task_updated_due_date.json @@ -0,0 +1,29 @@ +{ + "event": "taskUpdated", + "history_items": [ + { + "id": "2800792714143635886", + "type": 1, + "date": "1642736394447", + "field": "due_date", + "parent_id": "162641062", + "data": { + "due_date_time": true, + "old_due_date_time": false + }, + "source": null, + "user": { + "id": 183, + "username": "Pieter", + "email": "kwok.pieter@gmail.com", + "color": "#7b68ee", + "initials": "J", + "profilePicture": null + }, + "before": "1642701600000", + "after": "1643608800000" + } + ], + "task_id": "1vj38vv", + "webhook_id": "7fa3ec74-69a8-4530-a251-8a13730bd204" + } diff --git a/zerver/webhooks/clickup/fixtures/task_updated_priority.json b/zerver/webhooks/clickup/fixtures/task_updated_priority.json new file mode 100644 index 00000000000000..946dfeb0978c89 --- /dev/null +++ b/zerver/webhooks/clickup/fixtures/task_updated_priority.json @@ -0,0 +1,31 @@ +{ + "event": "taskUpdated", + "history_items": [ + { + "id": "2800773800802162647", + "type": 1, + "date": "1642735267148", + "field": "priority", + "parent_id": "162641062", + "data": {}, + "source": null, + "user": { + "id": 183, + "username": "Pieter", + "email": "kwok.pieter@gmail.com", + "color": "#7b68ee", + "initials": "J", + "profilePicture": null + }, + "before": null, + "after": { + "id": "2", + "priority": "high", + "color": "#ffcc00", + "orderindex": "2" + } + } + ], + "task_id": "1vj38vv", + "webhook_id": "7fa3ec74-69a8-4530-a251-8a13730bd204" + } diff --git a/zerver/webhooks/clickup/fixtures/task_updated_status.json b/zerver/webhooks/clickup/fixtures/task_updated_status.json new file mode 100644 index 00000000000000..33446fa5dc3b63 --- /dev/null +++ b/zerver/webhooks/clickup/fixtures/task_updated_status.json @@ -0,0 +1,38 @@ +{ + "event": "taskUpdated", + "history_items": [ + { + "id": "2800787326392370170", + "type": 1, + "date": "1642736073330", + "field": "status", + "parent_id": "162641062", + "data": { + "status_type": "custom" + }, + "source": null, + "user": { + "id": 183, + "username": "Pieter", + "email": "kwok.pieter@gmail.com", + "color": "#7b68ee", + "initials": "J", + "profilePicture": null + }, + "before": { + "status": "to do", + "color": "#f9d900", + "orderindex": 0, + "type": "open" + }, + "after": { + "status": "in progress", + "color": "#7C4DFF", + "orderindex": 1, + "type": "custom" + } + } + ], + "task_id": "1vj38vv", + "webhook_id": "7fa3ec74-69a8-4530-a251-8a13730bd204" +} diff --git a/zerver/webhooks/clickup/fixtures/task_updated_time_estimate.json b/zerver/webhooks/clickup/fixtures/task_updated_time_estimate.json new file mode 100644 index 00000000000000..20c0791e18814b --- /dev/null +++ b/zerver/webhooks/clickup/fixtures/task_updated_time_estimate.json @@ -0,0 +1,38 @@ +{ + "event": "taskUpdated", + "history_items": [ + { + "id": "2800808904123520175", + "type": 1, + "date": "1642737359443", + "field": "time_estimate", + "parent_id": "162641285", + "data": { + "time_estimate_string": "1 hour 30 minutes", + "old_time_estimate_string": null, + "rolled_up_time_estimate": 5400000, + "time_estimate": 5400000, + "time_estimates_by_user": [ + { + "userid": 2770032, + "user_time_estimate": "5400000", + "user_rollup_time_estimate": "5400000" + } + ] + }, + "source": null, + "user": { + "id": 183, + "username": "Pieter", + "email": "kwok.pieter@gmail.com", + "color": "#7b68ee", + "initials": "P", + "profilePicture": null + }, + "before": null, + "after": "5400000" + } + ], + "task_id": "1vj38vv", + "webhook_id": "7fa3ec74-69a8-4530-a251-8a13730bd204" + } diff --git a/zerver/webhooks/clickup/fixtures/task_updated_time_spent.json b/zerver/webhooks/clickup/fixtures/task_updated_time_spent.json new file mode 100644 index 00000000000000..248ed4b497fa08 --- /dev/null +++ b/zerver/webhooks/clickup/fixtures/task_updated_time_spent.json @@ -0,0 +1,37 @@ +{ + "event": "taskUpdated", + "history_items": [ + { + "id": "3945907824924417727", + "type": "1", + "date": "1710990573849", + "field": "time_spent", + "parent_id": "163597292", + "data": {"total_time": "68520000", "rollup_time": "68520000"}, + "source": null, + "user": { + "id": "37621629", + "username": "Pieter", + "email": "pieterceka123@gmail.com", + "color": "#5f7c8a", + "initials": "P", + "profilePicture": null + }, + "before": null, + "after": { + "id": "3945907824924425939", + "start": "1710972573656", + "end": "1710990573656", + "time": "18000000", + "source": "clickup", + "date_added": "1710990573849" + } + } + ], + "task_id": "860t7w26x", + "data": { + "description": "Time Tracking Created", + "interval_id": "3945907824924425939" + }, + "webhook_id": "4c21a84b-d0d8-41f7-978e-4fea0776f150" +} diff --git a/zerver/webhooks/clickup/tests.py b/zerver/webhooks/clickup/tests.py new file mode 100644 index 00000000000000..d5c69de61f2d93 --- /dev/null +++ b/zerver/webhooks/clickup/tests.py @@ -0,0 +1,498 @@ +import json +from typing import Any, Callable, Dict +from unittest.mock import MagicMock, patch + +from django.http import HttpRequest, HttpResponse +from requests.exceptions import ConnectionError, HTTPError, Timeout + +from zerver.decorator import webhook_view +from zerver.lib.test_classes import WebhookTestCase +from zerver.lib.test_helpers import HostRequestMock +from zerver.lib.users import get_api_key +from zerver.models import UserProfile +from zerver.webhooks.clickup.api_endpoints import ( + APIUnavailableCallBackError, + BadRequestCallBackError, + get_folder, + get_goal, + get_list, + get_space, + get_task, + make_clickup_request, +) + +EXPECTED_TOPIC = "ClickUp Notification" + + +class ClickUpHookTests(WebhookTestCase): + CHANNEL_NAME = "ClickUp" + URL_TEMPLATE = "/api/v1/external/clickup?api_key={api_key}&stream={stream}" + FIXTURE_DIR_NAME = "clickup" + WEBHOOK_DIR_NAME = "clickup" + + def test_task_created(self) -> None: + with patch("zerver.webhooks.clickup.view.get_task") as mock_get_task, open( + "zerver/webhooks/clickup/callback_fixtures/get_task.json" + ) as f: + mock_get_task.return_value = json.load(f) + self.url = self.build_webhook_url(team_id="XXXXXXXX", clickup_api_key="123") + expected_message = ( + ":new: **[Task: Tanswer](https://app.clickup.com/XXXXXXXX/home)** has been created in your ClickUp space!" + "\n - Created by: **Pieter CK**" + ) + + self.check_webhook( + fixture_name="task_created", + expected_topic_name=EXPECTED_TOPIC, + expected_message=expected_message, + ) + mock_get_task.assert_called_once() + + def test_task_deleted(self) -> None: + self.url = self.build_webhook_url(team_id="XXXXXXXX", clickup_api_key="123") + expected_message = ":trash_can: A Task has been deleted from your ClickUp space!" + + self.check_webhook( + fixture_name="task_deleted", + expected_topic_name=EXPECTED_TOPIC, + expected_message=expected_message, + ) + + def test_updated_time_spent(self) -> None: + with patch("zerver.webhooks.clickup.view.get_task") as mock_get_task, open( + "zerver/webhooks/clickup/callback_fixtures/get_task.json" + ) as f: + mock_get_task.return_value = json.load(f) + self.url = self.build_webhook_url(team_id="XXXXXXXX", clickup_api_key="123") + expected_message = ( + "**[Task: Tanswer](https://app.clickup.com/XXXXXXXX/home)** has been updated!\n" + "~~~ quote\n" + " :stopwatch: Time spent changed to **19:02:00**\n" + "~~~" + ) + + self.check_webhook( + fixture_name="task_updated_time_spent", + expected_topic_name=EXPECTED_TOPIC, + expected_message=expected_message, + ) + mock_get_task.assert_called_once() + + def test_task_updated_time_estimate(self) -> None: + with patch("zerver.webhooks.clickup.view.get_task") as mock_get_task, open( + "zerver/webhooks/clickup/callback_fixtures/get_task.json" + ) as f: + mock_get_task.return_value = json.load(f) + self.url = self.build_webhook_url(team_id="XXXXXXXX", clickup_api_key="123") + expected_message = ( + "**[Task: Tanswer](https://app.clickup.com/XXXXXXXX/home)** has been updated!\n" + "~~~ quote\n" + " :ruler: Time estimate changed from **None** to **1 hour 30 minutes** by **Pieter**\n" + "~~~" + ) + + self.check_webhook( + fixture_name="task_updated_time_estimate", + expected_topic_name=EXPECTED_TOPIC, + expected_message=expected_message, + ) + mock_get_task.assert_called_once() + + def test_task_updated_comment(self) -> None: + with patch("zerver.webhooks.clickup.view.get_task") as mock_get_task, open( + "zerver/webhooks/clickup/callback_fixtures/get_task.json" + ) as f: + mock_get_task.return_value = json.load(f) + self.url = self.build_webhook_url(team_id="XXXXXXXX", clickup_api_key="123") + expected_message = ( + "**[Task: Tanswer](https://app.clickup.com/XXXXXXXX/home)** has been updated!\n" + "~~~ quote\n" + " :speaking_head: Commented by **Pieter**\n" + "~~~" + ) + + self.check_webhook( + fixture_name="task_updated_comment", + expected_topic_name=EXPECTED_TOPIC, + expected_message=expected_message, + ) + mock_get_task.assert_called_once() + + def test_task_moved(self) -> None: + with patch("zerver.webhooks.clickup.view.get_task") as mock_get_task, open( + "zerver/webhooks/clickup/callback_fixtures/get_task.json" + ) as f: + mock_get_task.return_value = json.load(f) + self.url = self.build_webhook_url(team_id="XXXXXXXX", clickup_api_key="123") + expected_message = ( + "**[Task: Tanswer](https://app.clickup.com/XXXXXXXX/home)** has been updated!\n" + "~~~ quote\n" + " :folder: Moved from **Webhook payloads** to **webhook payloads 2**\n" + "~~~" + ) + + self.check_webhook( + fixture_name="task_moved", + expected_topic_name=EXPECTED_TOPIC, + expected_message=expected_message, + ) + mock_get_task.assert_called_once() + + def test_task_updated_assignee(self) -> None: + with patch("zerver.webhooks.clickup.view.get_task") as mock_get_task, open( + "zerver/webhooks/clickup/callback_fixtures/get_task.json" + ) as f: + mock_get_task.return_value = json.load(f) + self.url = self.build_webhook_url(team_id="XXXXXXXX", clickup_api_key="123") + expected_message = ( + "**[Task: Tanswer](https://app.clickup.com/XXXXXXXX/home)** has been updated!\n" + "~~~ quote\n" + " :silhouette: Now assigned to **Sam**\n" + "~~~" + ) + + self.check_webhook( + fixture_name="task_updated_assignee", + expected_topic_name=EXPECTED_TOPIC, + expected_message=expected_message, + ) + mock_get_task.assert_called_once() + + def test_task_updated_due_date(self) -> None: + with patch("zerver.webhooks.clickup.view.get_task") as mock_get_task, open( + "zerver/webhooks/clickup/callback_fixtures/get_task.json" + ) as f: + mock_get_task.return_value = json.load(f) + self.url = self.build_webhook_url(team_id="XXXXXXXX", clickup_api_key="123") + expected_message = ( + "**[Task: Tanswer](https://app.clickup.com/XXXXXXXX/home)** has been updated!\n" + "~~~ quote\n" + " :spiral_calendar: Due date updated from **2022-01-20** to **2022-01-31**\n" + "~~~" + ) + + self.check_webhook( + fixture_name="task_updated_due_date", + expected_topic_name=EXPECTED_TOPIC, + expected_message=expected_message, + ) + mock_get_task.assert_called_once() + + def test_task_updated_priority(self) -> None: + with patch("zerver.webhooks.clickup.view.get_task") as mock_get_task, open( + "zerver/webhooks/clickup/callback_fixtures/get_task.json" + ) as f: + mock_get_task.return_value = json.load(f) + self.url = self.build_webhook_url(team_id="XXXXXXXX", clickup_api_key="123") + expected_message = ( + "**[Task: Tanswer](https://app.clickup.com/XXXXXXXX/home)** has been updated!\n" + "~~~ quote\n" + " :note: Updated task priority from **None** to **high**\n" + "~~~" + ) + + self.check_webhook( + fixture_name="task_updated_priority", + expected_topic_name=EXPECTED_TOPIC, + expected_message=expected_message, + ) + mock_get_task.assert_called_once() + + def test_task_updated_status(self) -> None: + with patch("zerver.webhooks.clickup.view.get_task") as mock_get_task, open( + "zerver/webhooks/clickup/callback_fixtures/get_task.json" + ) as f: + mock_get_task.return_value = json.load(f) + self.url = self.build_webhook_url(team_id="XXXXXXXX", clickup_api_key="123") + expected_message = ( + "**[Task: Tanswer](https://app.clickup.com/XXXXXXXX/home)** has been updated!\n" + "~~~ quote\n" + " :note: Updated task status from **to do** to **in progress**\n" + "~~~" + ) + + self.check_webhook( + fixture_name="task_updated_status", + expected_topic_name=EXPECTED_TOPIC, + expected_message=expected_message, + ) + mock_get_task.assert_called_once() + + def test_list_created(self) -> None: + with patch("zerver.webhooks.clickup.view.get_list") as mock_get_list, open( + "zerver/webhooks/clickup/callback_fixtures/get_list.json" + ) as f: + mock_get_list.return_value = json.load(f) + self.url = self.build_webhook_url(team_id="XXXXXXXX", clickup_api_key="123") + expected_message = ":new: **[List: Listener](https://app.clickup.com/XXXXXXXX/home)** has been created in your ClickUp space!" + self.check_webhook( + fixture_name="list_created", + expected_topic_name=EXPECTED_TOPIC, + expected_message=expected_message, + ) + mock_get_list.assert_called_once() + + def test_list_deleted(self) -> None: + self.url = self.build_webhook_url(team_id="XXXXXXXX", clickup_api_key="123") + expected_message = ":trash_can: A List has been deleted from your ClickUp space!" + self.check_webhook( + fixture_name="list_deleted", + expected_topic_name=EXPECTED_TOPIC, + expected_message=expected_message, + ) + + def test_list_updated(self) -> None: + with patch("zerver.webhooks.clickup.view.get_list") as mock_get_list, open( + "zerver/webhooks/clickup/callback_fixtures/get_list.json" + ) as f: + mock_get_list.return_value = json.load(f) + self.url = self.build_webhook_url(team_id="XXXXXXXX", clickup_api_key="123") + expected_message = ( + "**[List: Listener](https://app.clickup.com/XXXXXXXX/home)** has been updated!\n" + "~~~ quote\n" + " :pencil: Renamed from **webhook payloads 2** to **Webhook payloads round 2**\n" + "~~~" + ) + self.check_webhook( + fixture_name="list_updated", + expected_topic_name=EXPECTED_TOPIC, + expected_message=expected_message, + ) + mock_get_list.assert_called_once() + + def test_folder_created(self) -> None: + with patch("zerver.webhooks.clickup.view.get_folder") as mock_get_folder, open( + "zerver/webhooks/clickup/callback_fixtures/get_folder.json" + ) as f: + mock_get_folder.return_value = json.load(f) + self.url = self.build_webhook_url(team_id="XXXXXXXX", clickup_api_key="123") + expected_message = ":new: **[Folder: Lord Foldemort](https://app.clickup.com/XXXXXXXX/home)** has been created in your ClickUp space!" + self.check_webhook( + fixture_name="folder_created", + expected_topic_name=EXPECTED_TOPIC, + expected_message=expected_message, + ) + mock_get_folder.assert_called_once() + + def test_folder_deleted(self) -> None: + self.url = self.build_webhook_url(team_id="XXXXXXXX", clickup_api_key="123") + expected_message = ":trash_can: A Folder has been deleted from your ClickUp space!" + self.check_webhook( + fixture_name="folder_deleted", + expected_topic_name=EXPECTED_TOPIC, + expected_message=expected_message, + ) + + def test_space_created(self) -> None: + with patch("zerver.webhooks.clickup.view.get_space") as mock_get_space, open( + "zerver/webhooks/clickup/callback_fixtures/get_space.json" + ) as f: + mock_get_space.return_value = json.load(f) + self.url = self.build_webhook_url(team_id="XXXXXXXX", clickup_api_key="123") + expected_message = ":new: **[Space: the Milky Way](https://app.clickup.com/XXXXXXXX/home)** has been created in your ClickUp space!" + self.check_webhook( + fixture_name="space_created", + expected_topic_name=EXPECTED_TOPIC, + expected_message=expected_message, + ) + mock_get_space.assert_called_once() + + def test_space_deleted(self) -> None: + self.url = self.build_webhook_url(team_id="XXXXXXXX", clickup_api_key="123") + expected_message = ":trash_can: A Space has been deleted from your ClickUp space!" + self.check_webhook( + fixture_name="space_deleted", + expected_topic_name=EXPECTED_TOPIC, + expected_message=expected_message, + ) + + def test_space_updated(self) -> None: + with patch("zerver.webhooks.clickup.view.get_space") as mock_get_space, open( + "zerver/webhooks/clickup/callback_fixtures/get_space.json" + ) as f: + mock_get_space.return_value = json.load(f) + self.url = self.build_webhook_url(team_id="XXXXXXXX", clickup_api_key="123") + expected_message = "**[Space: the Milky Way](https://app.clickup.com/XXXXXXXX/home)** has been updated!" + self.check_webhook( + fixture_name="space_updated", + expected_topic_name=EXPECTED_TOPIC, + expected_message=expected_message, + ) + mock_get_space.assert_called_once() + self.url = self.build_webhook_url(team_id="XXXXXXXX", clickup_api_key="123") + + def test_goal_created(self) -> None: + with patch("zerver.webhooks.clickup.view.get_goal") as mock_get_goal, open( + "zerver/webhooks/clickup/callback_fixtures/get_goal.json" + ) as f: + mock_get_goal.return_value = json.load(f) + self.url = self.build_webhook_url(team_id="XXXXXXXX", clickup_api_key="123") + expected_message = ":new: **[Goal: hat-trick](https://app.clickup.com/512/goals/6)** has been created in your ClickUp space!" + self.check_webhook( + fixture_name="goal_created", + expected_topic_name=EXPECTED_TOPIC, + expected_message=expected_message, + ) + mock_get_goal.assert_called_once() + + def test_goal_updated(self) -> None: + with patch("zerver.webhooks.clickup.view.get_goal") as mock_get_goal, open( + "zerver/webhooks/clickup/callback_fixtures/get_goal.json" + ) as f: + mock_get_goal.return_value = json.load(f) + self.url = self.build_webhook_url(team_id="XXXXXXXX", clickup_api_key="123") + expected_message = ( + "**[Goal: hat-trick](https://app.clickup.com/512/goals/6)** has been updated!" + ) + self.check_webhook( + fixture_name="goal_updated", + expected_topic_name=EXPECTED_TOPIC, + expected_message=expected_message, + ) + mock_get_goal.assert_called_once() + + def test_goal_deleted(self) -> None: + self.url = self.build_webhook_url(team_id="XXXXXXXX", clickup_api_key="123") + expected_message = ":trash_can: A Goal has been deleted from your ClickUp space!" + self.check_webhook( + fixture_name="goal_deleted", + expected_topic_name=EXPECTED_TOPIC, + expected_message=expected_message, + ) + + def test_missing_request_variable(self) -> None: + self.url = self.build_webhook_url() + exception_msg = "Missing 'clickup_api_key' argument" + with self.assertRaisesRegex(AssertionError, exception_msg): + expected_message = ":trash_can: A Goal has been deleted from your ClickUp space!" + self.check_webhook( + fixture_name="goal_deleted", + expected_topic_name=EXPECTED_TOPIC, + expected_message=expected_message, + ) + + def test_webhook_api_callback_unavailable_error(self) -> None: + @webhook_view("ClientName") + def my_webhook_raises_exception( + request: HttpRequest, user_profile: UserProfile + ) -> HttpResponse: + raise APIUnavailableCallBackError + + request = HostRequestMock() + request.method = "POST" + request.host = "zulip.testserver" + + request._body = b"{}" + request.content_type = "text/plain" + request.POST["api_key"] = get_api_key(self.example_user("hamlet")) + exception_msg = "ClientName integration couldn't reach an external API service; ignoring" + with patch( + "zerver.decorator.webhook_logger.exception" + ) as mock_exception, self.assertRaisesRegex(APIUnavailableCallBackError, exception_msg): + my_webhook_raises_exception(request) + mock_exception.assert_called_once() + self.assertIsInstance(mock_exception.call_args.args[0], APIUnavailableCallBackError) + self.assertEqual(mock_exception.call_args.args[0].msg, exception_msg) + self.assertEqual( + mock_exception.call_args.kwargs, {"extra": {"request": request}, "stack_info": True} + ) + + def test_webhook_api_callback_bad_request_error(self) -> None: + @webhook_view(webhook_client_name="ClientName") + def my_webhook_raises_exception( + request: HttpRequest, user_profile: UserProfile + ) -> HttpResponse: + raise BadRequestCallBackError("") + + request = HostRequestMock() + request.method = "POST" + request.host = "zulip.testserver" + + request._body = b"{}" + request.content_type = "text/plain" + request.POST["api_key"] = get_api_key(self.example_user("hamlet")) + exception_msg = ( + "ClientName integration tries to make a bad outgoing request: ; ignoring" + ) + with patch( + "zerver.decorator.webhook_logger.exception" + ) as mock_exception, self.assertRaisesRegex(BadRequestCallBackError, exception_msg): + my_webhook_raises_exception(request) + mock_exception.assert_called_once() + self.assertIsInstance(mock_exception.call_args.args[0], BadRequestCallBackError) + self.assertEqual(mock_exception.call_args.args[0].msg, exception_msg) + self.assertEqual( + mock_exception.call_args.kwargs, {"extra": {"request": request}, "stack_info": True} + ) + + def test_verify_url_path(self) -> None: + invalid_paths = ["oauth/token", "user", "webhook"] + for path in invalid_paths: + with self.assertRaises(BadRequestCallBackError): + make_clickup_request(path, api_key="123") + + def test_clickup_request_http_error(self) -> None: + with patch("zerver.webhooks.clickup.api_endpoints.ClickUpSession") as mock_clickup_session: + mock_response = MagicMock() + mock_response.status_code = 404 + mock_clickup_session.return_value.get.side_effect = HTTPError(response=mock_response) + with self.assertRaises(BadRequestCallBackError): + make_clickup_request("list/123123", api_key="123") + mock_clickup_session.return_value.get.assert_called_once() + + def test_clickup_request_connection_error(self) -> None: + with patch("zerver.webhooks.clickup.api_endpoints.ClickUpSession") as mock_clickup_session: + mock_response = MagicMock() + mock_clickup_session.return_value.get.side_effect = ConnectionError( + response=mock_response + ) + with self.assertRaises(APIUnavailableCallBackError): + make_clickup_request("list/123123", api_key="123") + mock_clickup_session.return_value.get.assert_called_once() + + def test_clickup_request_timeout_error(self) -> None: + with patch("zerver.webhooks.clickup.api_endpoints.ClickUpSession") as mock_clickup_session: + mock_response = MagicMock() + mock_clickup_session.return_value.get.side_effect = Timeout(response=mock_response) + with self.assertRaises(APIUnavailableCallBackError): + make_clickup_request("list/123123", api_key="123") + mock_clickup_session.return_value.get.assert_called_once() + + def test_clickup_api_endpoints(self) -> None: + endpoint_map: Dict[str, Callable[[str, str], Dict[str, Any]]] = { + "folder": get_folder, + "list": get_list, + "space": get_space, + "task": get_task, + "goal": get_goal, + } + for item, call_api in endpoint_map.items(): + mock_fixtures_path = f"zerver/webhooks/clickup/callback_fixtures/get_{item}.json" + with patch( + "zerver.webhooks.clickup.api_endpoints.ClickUpSession" + ) as mock_clickup_session, open(mock_fixtures_path) as f: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.raise_for_status.side_effect = None + item_fixture = json.load(f) + mock_response.json.return_value = item_fixture + mock_clickup_session.return_value.get.return_value = mock_response + item_data = call_api("123", "XXXX") + + self.assertDictEqual(item_data, item_fixture) + mock_clickup_session.return_value.get.assert_called_once() + + def test_payload_with_spammy_field(self) -> None: + with patch("zerver.webhooks.clickup.view.get_task") as mock_get_task, open( + "zerver/webhooks/clickup/callback_fixtures/get_task.json" + ) as f: + mock_get_task.return_value = json.load(f) + self.url = self.build_webhook_url(team_id="XXXXXXXX", clickup_api_key="123") + expected_message = ( + "**[Task: Tanswer](https://app.clickup.com/XXXXXXXX/home)** has been updated!" + ) + self.check_webhook( + fixture_name="payload_with_spammy_field", + expected_topic_name=EXPECTED_TOPIC, + expected_message=expected_message, + ) diff --git a/zerver/webhooks/clickup/view.py b/zerver/webhooks/clickup/view.py new file mode 100644 index 00000000000000..4695fc2cc7a82b --- /dev/null +++ b/zerver/webhooks/clickup/view.py @@ -0,0 +1,250 @@ +# Webhooks for external integrations. +import logging +import re +from typing import Any, Dict, Tuple + +from django.http import HttpRequest, HttpResponse + +from zerver.decorator import webhook_view +from zerver.lib.exceptions import UnsupportedWebhookEventTypeError +from zerver.lib.request import REQ, has_request_variables +from zerver.lib.response import json_success +from zerver.lib.typed_endpoint import JsonBodyPayload, typed_endpoint +from zerver.lib.validator import WildValue, check_string +from zerver.lib.webhooks.common import check_send_webhook_message, unix_milliseconds_to_timestamp +from zerver.models import UserProfile +from zerver.webhooks.clickup import ( + SIMPLE_FIELDS, + SPAMMY_FIELDS, + EventAcion, + EventItemType, + SpecialFields, +) + +from .api_endpoints import get_folder, get_goal, get_list, get_space, get_task + +logger = logging.getLogger(__name__) + +EVENT_NAME_TEMPLATE: str = "**[{event_item_type}: {event_item_name}]({item_url})**" + + +def topic_and_body(payload: WildValue, clickup_api_key: str, team_id: str) -> Tuple[str, str]: + event_code = payload["event"].tame(check_string) + topic = "ClickUp Notification" + + event_item_type, event_action = parse_event_code(event_code=event_code) + + if event_action == EventAcion.DELETED.value: + body = generate_delete_event_message(event_item_type=event_item_type) + return topic, body + + item_data = get_item_data( + event_item_type=event_item_type, + api_key=clickup_api_key, + payload=payload, + team_id=team_id, + ) + if event_action == EventAcion.CREATED.value: + body = generate_create_event_message(item_data=item_data, event_item_type=event_item_type) + + elif event_action == EventAcion.UPDATED.value: + body = generate_updated_event_message( + item_data=item_data, payload=payload, event_item_type=event_item_type + ) + else: + raise UnsupportedWebhookEventTypeError(event_code) + + return topic, body + + +def parse_event_code(event_code: str) -> Tuple[str, str]: + item_type_pattern: str = "|".join(EventItemType.as_list()) + action_pattern: str = "|".join(EventAcion.as_list()) + pattern = rf"(?P({item_type_pattern}))(?P({action_pattern}))" + match = re.match(pattern, event_code) + if match is None or match.group("item_type") is None or match.group("event_action") is None: + raise UnsupportedWebhookEventTypeError(event_code) + + return match.group("item_type"), match.group("event_action") + + +def generate_create_event_message(item_data: Dict[str, Any], event_item_type: str) -> str: + created_message = "\n:new: " + EVENT_NAME_TEMPLATE + " has been created in your ClickUp space!" + if isinstance(item_data.get("creator"), dict) and item_data["creator"].get("username"): + # some payload only provide creator id, not dict of usable data. + created_message += "\n - Created by: **{event_user}**".format( + event_user=item_data["creator"]["username"] + ) + + return created_message.format( + event_item_type=event_item_type.title(), + event_item_name=item_data["name"], + item_url=item_data["url"], + ) + + +def generate_delete_event_message(event_item_type: str) -> str: + return f"\n:trash_can: A {event_item_type.title()} has been deleted from your ClickUp space!" + + +def generate_updated_event_message( + item_data: Dict[str, Any], payload: WildValue, event_item_type: str +) -> str: + """ + Appends all the details of the updated fields to the message body. + """ + body = "\n" + EVENT_NAME_TEMPLATE + " has been updated!" + history_items = payload.get("history_items") + + if history_items: + for history_data in history_items: + updated_field = history_data["field"].tame(check_string) + if updated_field in SPAMMY_FIELDS: + # Updating these fields may trigger multiple identical notifications at a time. + continue # nocoverage + elif updated_field in SIMPLE_FIELDS: + body += body_message_for_simple_field( + history_data=history_data, event_item_type=event_item_type + ) + elif updated_field in SpecialFields.as_list(): + body += body_message_for_special_field(history_data=history_data) + else: + raise UnsupportedWebhookEventTypeError(updated_field) + + return body.format( + event_item_type=event_item_type.title(), + event_item_name=item_data["name"], + item_url=item_data["url"], + ) + + +def body_message_for_simple_field(history_data: WildValue, event_item_type: str) -> str: + updated_field = history_data["field"].tame(check_string) + old_value = ( + history_data.get("before").get(updated_field).tame(check_string) + if history_data.get("before") + else None + ) + new_value = ( + history_data.get("after").get(updated_field).tame(check_string) + if history_data.get("after") + else None + ) + return f"\n~~~ quote\n :note: Updated {event_item_type} {updated_field} from **{old_value}** to **{new_value}**\n~~~\n" + + +def body_message_for_special_field(history_data: WildValue) -> str: + updated_field = history_data["field"].tame(check_string) + if updated_field == SpecialFields.NAME.value: + return ( + "\n~~~ quote\n :pencil: Renamed from **{old_value}** to **{new_value}**\n~~~\n" + ).format( + old_value=history_data["before"].tame(check_string), + new_value=history_data["after"].tame(check_string), + ) + + elif updated_field == SpecialFields.ASSIGNEE.value: + return ("\n~~~ quote\n :silhouette: Now assigned to **{new_value}**\n~~~\n").format( + new_value=history_data["after"]["username"].tame(check_string) + ) + + elif updated_field == SpecialFields.COMMENT.value: + return ("\n~~~ quote\n :speaking_head: Commented by **{event_user}**\n~~~\n").format( + event_user=history_data["user"]["username"].tame(check_string) + ) + + elif updated_field == SpecialFields.DUE_DATE.value: + old_value = ( + history_data.get("before").tame(check_string) if history_data.get("before") else None + ) + old_due_date = ( + unix_milliseconds_to_timestamp( + milliseconds=float(old_value), webhook="ClickUp" + ).strftime("%Y-%m-%d") + if old_value + else None + ) + new_value = ( + history_data.get("after").tame(check_string) if history_data.get("after") else None + ) + new_due_date = ( + unix_milliseconds_to_timestamp( + milliseconds=float(new_value), webhook="ClickUp" + ).strftime("%Y-%m-%d") + if new_value + else None + ) + return f"\n~~~ quote\n :spiral_calendar: Due date updated from **{old_due_date}** to **{new_due_date}**\n~~~\n" + + elif updated_field == SpecialFields.MOVED.value: + raw_old_value = history_data.get("before", {}).get("name") + old_value = raw_old_value.tame(check_string) if raw_old_value else None + raw_new_value = history_data.get("after", {}).get("name") + new_value = raw_new_value.tame(check_string) if raw_new_value else None + return f"\n~~~ quote\n :folder: Moved from **{old_value}** to **{new_value}**\n~~~\n" + + elif updated_field == SpecialFields.TIME_SPENT.value: + raw_time_spent = history_data.get("data", {}).get("total_time").tame(check_string) + new_time_spent = ( + unix_milliseconds_to_timestamp( + milliseconds=float(raw_time_spent), webhook="ClickUp" + ).strftime("%H:%M:%S") + if raw_time_spent + else None + ) + return f"\n~~~ quote\n :stopwatch: Time spent changed to **{new_time_spent}**\n~~~\n" + elif updated_field == SpecialFields.TIME_ESTIMATE.value: + raw_old_value = history_data.get("data", {}).get("old_time_estimate_string") + old_value = raw_old_value.tame(check_string) if raw_old_value else None + raw_new_value = history_data.get("data", {}).get("time_estimate_string") + new_value = raw_new_value.tame(check_string) if raw_new_value else None + raw_event_user = history_data.get("user", {}).get("username").tame(check_string) + event_user = raw_event_user if raw_event_user else None + return f"\n~~~ quote\n :ruler: Time estimate changed from **{old_value}** to **{new_value}** by **{event_user}**\n~~~\n" + else: + raise UnsupportedWebhookEventTypeError(updated_field) + + +def get_item_data( + event_item_type: str, api_key: str, payload: WildValue, team_id: str +) -> Dict[str, Any]: + item_data: Dict[str, Any] = {} + if event_item_type == EventItemType.TASK.value: + item_data = get_task(api_key=str(api_key), task_id=payload["task_id"].tame(check_string)) + elif event_item_type == EventItemType.LIST.value: + item_data = get_list(api_key=api_key, list_id=payload["list_id"].tame(check_string)) + elif event_item_type == EventItemType.FOLDER.value: + item_data = get_folder(api_key=api_key, folder_id=payload["folder_id"].tame(check_string)) + elif event_item_type == EventItemType.GOAL.value: + goal_data: Dict[str, Any] = get_goal( + api_key=api_key, goal_id=payload["goal_id"].tame(check_string) + ) + item_data = goal_data.get( + "goal", {} + ) # in case of Goal payload, useful data are stored 1 level deeper + elif event_item_type == EventItemType.SPACE.value: + item_data = get_space(api_key=api_key, space_id=payload["space_id"].tame(check_string)) + else: + raise UnsupportedWebhookEventTypeError(event_item_type) + + if item_data.get("pretty_url") and not item_data.get("url"): + item_data["url"] = item_data["pretty_url"] + if not item_data.get("url"): + item_data["url"] = "https://app.clickup.com/" + team_id + "/home" + return item_data + + +@webhook_view("ClickUp") +@typed_endpoint +@has_request_variables +def api_clickup_webhook( + request: HttpRequest, + user_profile: UserProfile, + clickup_api_key: str = REQ(), + team_id: str = REQ(), + *, + payload: JsonBodyPayload[WildValue], +) -> HttpResponse: + topic, body = topic_and_body(payload, clickup_api_key, team_id) + check_send_webhook_message(request, user_profile, topic, body) + return json_success(request)