diff --git a/static/images/integrations/bot_avatars/clickup.png b/static/images/integrations/bot_avatars/clickup.png new file mode 100644 index 00000000000000..39197b44d32309 Binary files /dev/null and b/static/images/integrations/bot_avatars/clickup.png differ diff --git a/static/images/integrations/clickup/001.png b/static/images/integrations/clickup/001.png new file mode 100644 index 00000000000000..17d5ccda4b5070 Binary files /dev/null and b/static/images/integrations/clickup/001.png differ diff --git a/static/images/integrations/clickup/002.png b/static/images/integrations/clickup/002.png new file mode 100644 index 00000000000000..c2f51c0c0982e5 Binary files /dev/null and b/static/images/integrations/clickup/002.png differ diff --git a/static/images/integrations/logos/clickup.svg b/static/images/integrations/logos/clickup.svg new file mode 100644 index 00000000000000..18f875d5cc8500 --- /dev/null +++ b/static/images/integrations/logos/clickup.svg @@ -0,0 +1 @@ + \ 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