From e4883b824948e9ef3a1d551955cd67da1febad1c Mon Sep 17 00:00:00 2001 From: PieterCK Date: Tue, 2 Apr 2024 03:40:04 +0000 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. --- zerver/lib/integrations.py | 1 + zerver/webhooks/clickup/__init__.py | 45 ++++++++++ zerver/webhooks/clickup/api_endpoints.py | 16 +++- .../clickup/callback_fixtures/get_folder.json | 24 +++--- .../clickup/callback_fixtures/get_goal.json | 60 ++++++------- .../clickup/callback_fixtures/get_list.json | 50 +++++------ .../clickup/callback_fixtures/get_space.json | 86 +++++++++---------- .../clickup/callback_fixtures/get_task.json | 70 +++++++-------- zerver/webhooks/clickup/doc.md | 13 ++- zerver/webhooks/clickup/tests.py | 15 +++- zerver/webhooks/clickup/view.py | 73 +++------------- 11 files changed, 235 insertions(+), 218 deletions(-) diff --git a/zerver/lib/integrations.py b/zerver/lib/integrations.py index 8a6d9b7ee66d56..357ee66a6d7fec 100644 --- a/zerver/lib/integrations.py +++ b/zerver/lib/integrations.py @@ -8,6 +8,7 @@ from django.utils.translation import gettext_lazy from django_stubs_ext import StrPromise from typing_extensions import TypeAlias + from zerver.lib.storage import static_path """This module declares all of the (documented) integrations available diff --git a/zerver/webhooks/clickup/__init__.py b/zerver/webhooks/clickup/__init__.py index e69de29bb2d1d6..e3acaa089e4ac8 100644 --- a/zerver/webhooks/clickup/__init__.py +++ 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 index a74ab91ea70811..3ce745ba84bb0b 100644 --- a/zerver/webhooks/clickup/api_endpoints.py +++ b/zerver/webhooks/clickup/api_endpoints.py @@ -1,8 +1,11 @@ +import re from typing import Any, Dict +from urllib.parse import urljoin import requests -from urllib.parse import urljoin + from zerver.lib.outgoing_http import OutgoingSession +from zerver.webhooks.clickup import EventItemType class Error(Exception): @@ -22,7 +25,18 @@ def __init__(self, **kwargs: Any) -> None: super().__init__(role="clickup", timeout=5, **kwargs) +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 BadRequestError("Invalid path") headers: Dict[str, str] = { "Content-Type": "application/json", "Authorization": api_key, diff --git a/zerver/webhooks/clickup/callback_fixtures/get_folder.json b/zerver/webhooks/clickup/callback_fixtures/get_folder.json index 6f873ac0ec94d1..f646c9fdac0520 100644 --- a/zerver/webhooks/clickup/callback_fixtures/get_folder.json +++ b/zerver/webhooks/clickup/callback_fixtures/get_folder.json @@ -1,14 +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": [] + "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 index afcf3af54b617c..733317c1e2be02 100644 --- a/zerver/webhooks/clickup/callback_fixtures/get_goal.json +++ b/zerver/webhooks/clickup/callback_fixtures/get_goal.json @@ -1,33 +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" + "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 index 657f3cd1d6bf27..b23ba40f55f3ce 100644 --- a/zerver/webhooks/clickup/callback_fixtures/get_list.json +++ b/zerver/webhooks/clickup/callback_fixtures/get_list.json @@ -4,13 +4,13 @@ "orderindex": 1, "content": "Updated List Content", "status": { - "status": "red", - "color": "#e50000", - "hide_label": true + "status": "red", + "color": "#e50000", + "hide_label": true }, "priority": { - "priority": "high", - "color": "#f50000" + "priority": "high", + "color": "#f50000" }, "assignee": null, "due_date": "1567780450202", @@ -18,32 +18,32 @@ "start_date": null, "start_date_time": null, "folder": { - "id": "456", - "name": "Folder Name", - "hidden": false, - "access": true + "id": "456", + "name": "Folder Name", + "hidden": false, + "access": true }, "space": { - "id": "789", - "name": "Space Name", - "access": true + "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" - } + { + "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 index e12d535cdb0812..d19af504b23fb4 100644 --- a/zerver/webhooks/clickup/callback_fixtures/get_space.json +++ b/zerver/webhooks/clickup/callback_fixtures/get_space.json @@ -3,50 +3,50 @@ "name": "the Milky Way", "private": false, "statuses": [ - { - "status": "to do", - "type": "open", - "orderindex": 0, - "color": "#d3d3d3" - }, - { - "status": "complete", - "type": "closed", - "orderindex": 1, - "color": "#6bc950" - } + { + "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 - } + "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 index 3284ac99ff1c57..146db98e6ad868 100644 --- a/zerver/webhooks/clickup/callback_fixtures/get_task.json +++ b/zerver/webhooks/clickup/callback_fixtures/get_task.json @@ -6,30 +6,24 @@ "text_content": "string", "description": "string", "status": { - "status": "in progress", - "color": "#d3d3d3", - "orderindex": 1, - "type": "custom" + "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" + "id": 183, + "username": "Pieter CK", + "color": "#827718", + "profilePicture": "https://attachments-public.clickup.com/profilePictures/183_abc.jpg" }, - "assignees": [ - "string" - ], - "checklists": [ - "string" - ], - "tags": [ - "string" - ], + "assignees": ["string"], + "checklists": ["string"], + "tags": ["string"], "parent": "string", "priority": "string", "due_date": "string", @@ -37,33 +31,33 @@ "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 - } + { + "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" + "id": "123" }, "folder": { - "id": "456" + "id": "456" }, "space": { - "id": "789" + "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 index cd29c5744c970e..1db478df6f2e9e 100644 --- a/zerver/webhooks/clickup/doc.md +++ b/zerver/webhooks/clickup/doc.md @@ -16,7 +16,7 @@ Get Zulip notifications from your ClickUp space! You're now going to need to run a ClickUp Integration configuration script from a computer (any computer) connected to the internet. It won't make any changes to the computer. - + 1. Make sure you have a working copy of Python. If you're running macOS or Linux, you very likely already do. If you're running Windows you may or may not. If you don't have Python, follow the @@ -34,11 +34,10 @@ Get Zulip notifications from your ClickUp space! ``. * **Client ID & Client Secret**: Please follow the instructions below: - - Go to and click **Create an App** button. - After that, you will be prompted for Redirect URL(s). You must enter your zulip app URL. - e.g. `YourZulipApp.com`. + e.g. `YourZulipApp.com`. - Finally, note down the **Client ID** and **Client Secret** @@ -50,17 +49,17 @@ Get Zulip notifications from your ClickUp space! --clickup-client-id CLIENT_ID \ --clickup-client-secret CLIENT_SECRET ``` - + The `zulip-clickup.py` script only needs to be run once, and can be run on any computer with python. -1. Follow the instructions given by the script. +1. Follow the instructions given by the script. **Note:** You will be prompted for the **integration url** you just generated in step 2 and watch your browser since you will be redirected to a ClickUp authorization page to proceed. -1. You can delete `zulip-clickup.py` from your computer if you'd like or run it again to +1. You can delete `zulip-clickup.py` from your computer if you'd like or run it again to reconfigure your ClickUp integration. -[2]: https://raw.githubusercontent.com/zulip/python-zulip-api/main/zulip/integrations/trello/zulip_trello.py +[2]: https://raw.githubusercontent.com/zulip/python-zulip-api/main/zulip/integrations/clickup/zulip_clickup.py {!congrats.md!} diff --git a/zerver/webhooks/clickup/tests.py b/zerver/webhooks/clickup/tests.py index 743c6d73a9627c..25e13e1d135120 100644 --- a/zerver/webhooks/clickup/tests.py +++ b/zerver/webhooks/clickup/tests.py @@ -1,7 +1,8 @@ -from zerver.lib.test_classes import WebhookTestCase +import json from unittest.mock import patch -import json +from zerver.lib.test_classes import WebhookTestCase +from zerver.webhooks.clickup.api_endpoints import BadRequestError, make_clickup_request EXPECTED_TOPIC = "ClickUp Notification" @@ -341,3 +342,13 @@ def test_goal_deleted(self) -> None: expected_topic_name=EXPECTED_TOPIC, expected_message=expected_message, ) + + def test_verify_url_path(self) -> None: + invalid_paths = [ + "oauth/token", + "user", + "webhook" + ] + for path in invalid_paths: + with self.assertRaises(BadRequestError): + make_clickup_request(path,api_key="123") diff --git a/zerver/webhooks/clickup/view.py b/zerver/webhooks/clickup/view.py index 507f0b6644ff49..91ba53d481f4b4 100644 --- a/zerver/webhooks/clickup/view.py +++ b/zerver/webhooks/clickup/view.py @@ -1,11 +1,9 @@ # Webhooks for external integrations. -from enum import Enum -from typing import Any, Dict, List, Tuple - -from django.http import HttpRequest, HttpResponse - 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 @@ -15,6 +13,13 @@ 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 ( APIUnavailableError, @@ -26,53 +31,8 @@ get_task, ) - logger = logging.getLogger(__name__) - -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" - - EVENT_NAME_TEMPLATE: str = "**[{event_item_type}: {event_item_name}]({item_url})**" @@ -178,7 +138,7 @@ def generate_updated_event_message( 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 notifications at a time. + # Updating these fields may trigger multiple identical notifications at a time. raise UnsupportedWebhookEventTypeError(updated_field) elif updated_field in SimpleFields.as_list(): body += body_message_for_simple_field( @@ -208,14 +168,7 @@ def body_message_for_simple_field(history_data: WildValue, event_item_type: str) if history_data.get("after") else None ) - return ( - "\n~~~ quote\n :note: Updated {event_item_type} {updated_field} from **{old_value}** to **{new_value}**\n~~~\n" - ).format( - event_item_type=event_item_type, - updated_field=updated_field, - old_value=old_value, - new_value=new_value, - ) + 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: @@ -306,7 +259,7 @@ def get_item_data( ) item_data.update( goal_data.get("goal") - ) # in case of Goal payload, useful datas are stored 1 level deeper + ) # 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: