From 3738ed24678922c8bd1e54f3b730fe9ef0ae1534 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 -> 27200 bytes static/images/integrations/clickup/002.png | Bin 0 -> 28278 bytes static/images/integrations/logos/clickup.svg | 1 + zerver/lib/integrations.py | 2 + zerver/webhooks/clickup/__init__.py | 45 ++ 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 | 60 +++ .../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 + .../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 | 483 ++++++++++++++++++ zerver/webhooks/clickup/view.py | 250 +++++++++ 38 files changed, 1681 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/clickup/002.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/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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

#_$_X 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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..e3acaa089e4ac8 --- /dev/null +++ b/zerver/webhooks/clickup/__init__.py @@ -0,0 +1,45 @@ +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" + + +class SimpleFields(ConstantVariable): + # Events with identical payload format + PRIORITY: str = "priority" + STATUS: str = "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" + + +class SpammyFields(ConstantVariable): + TAG: str = "tag" + TAG_REMOVED: str = "tag_removed" + UNASSIGN: str = "assignee_rem" diff --git a/zerver/webhooks/clickup/api_endpoints.py b/zerver/webhooks/clickup/api_endpoints.py new file mode 100644 index 00000000000000..36ec6877b9f9fd --- /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..b23ba40f55f3ce --- /dev/null +++ b/zerver/webhooks/clickup/callback_fixtures/get_list.json @@ -0,0 +1,49 @@ +{ + "id": "124", + "name": "List-an al Gaib", + "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..238acad4d9499b --- /dev/null +++ b/zerver/webhooks/clickup/doc.md @@ -0,0 +1,60 @@ +# 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-stream.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** button. + + - 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/002.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/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..95f9b7cfdbd6d5 --- /dev/null +++ b/zerver/webhooks/clickup/tests.py @@ -0,0 +1,483 @@ +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): + STREAM_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: List-an al Gaib](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: List-an al Gaib](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() diff --git a/zerver/webhooks/clickup/view.py b/zerver/webhooks/clickup/view.py new file mode 100644 index 00000000000000..12a83e0ef01e28 --- /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 ( + EventAcion, + EventItemType, + SimpleFields, + SpammyFields, + 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})**" + + +@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) + + +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 SpammyFields.as_list(): + # Updating these fields may trigger multiple identical notifications at a time. + continue # nocoverage + elif updated_field in SimpleFields.as_list(): + 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