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