From de6bc70633fd8f2a5f3b7709cfebaf40f18f9554 Mon Sep 17 00:00:00 2001 From: pieterck Date: Wed, 20 Mar 2024 23:16:42 +0700 Subject: [PATCH] integrations: Add ClickUp integration. Creates an incoming webhook integration for ClickUp. The main use case is getting notifications when new ClickUp items such as task, list, folder, space, goals are created, updated or deleted. Fixes zulip#26529. --- .../integrations/bot_avatars/clickup.png | Bin 0 -> 3503 bytes static/images/integrations/clickup/001.png | Bin 0 -> 28278 bytes static/images/integrations/logos/clickup.svg | 1 + zerver/lib/integrations.py | 2 + zerver/webhooks/clickup/__init__.py | 1 + .../clickup/callback_fixtures/get_folder.json | 14 + .../clickup/callback_fixtures/get_goal.json | 33 ++ .../clickup/callback_fixtures/get_list.json | 49 +++ .../clickup/callback_fixtures/get_space.json | 52 +++ .../clickup/callback_fixtures/get_task.json | 63 ++++ zerver/webhooks/clickup/doc.md | 88 +++++ .../clickup/fixtures/folder_created.json | 5 + .../clickup/fixtures/folder_deleted.json | 5 + .../clickup/fixtures/folder_updated.json | 5 + .../clickup/fixtures/goal_created.json | 5 + .../clickup/fixtures/goal_deleted.json | 5 + .../clickup/fixtures/goal_updated.json | 5 + .../clickup/fixtures/list_created.json | 5 + .../clickup/fixtures/list_deleted.json | 5 + .../clickup/fixtures/list_updated.json | 26 ++ .../fixtures/payload_with_spammy_field.json | 33 ++ .../clickup/fixtures/space_created.json | 5 + .../clickup/fixtures/space_deleted.json | 5 + .../clickup/fixtures/space_updated.json | 5 + .../clickup/fixtures/task_created.json | 57 ++++ .../clickup/fixtures/task_deleted.json | 5 + .../webhooks/clickup/fixtures/task_moved.json | 52 +++ .../fixtures/task_updated_assignee.json | 32 ++ .../fixtures/task_updated_comment.json | 85 +++++ .../fixtures/task_updated_due_date.json | 29 ++ .../fixtures/task_updated_priority.json | 31 ++ .../clickup/fixtures/task_updated_status.json | 38 +++ .../fixtures/task_updated_time_estimate.json | 38 +++ .../fixtures/task_updated_time_spent.json | 37 ++ zerver/webhooks/clickup/tests.py | 323 ++++++++++++++++++ zerver/webhooks/clickup/view.py | 248 ++++++++++++++ 36 files changed, 1392 insertions(+) create mode 100644 static/images/integrations/bot_avatars/clickup.png create mode 100644 static/images/integrations/clickup/001.png create mode 100644 static/images/integrations/logos/clickup.svg create mode 100644 zerver/webhooks/clickup/__init__.py create mode 100644 zerver/webhooks/clickup/callback_fixtures/get_folder.json create mode 100644 zerver/webhooks/clickup/callback_fixtures/get_goal.json create mode 100644 zerver/webhooks/clickup/callback_fixtures/get_list.json create mode 100644 zerver/webhooks/clickup/callback_fixtures/get_space.json create mode 100644 zerver/webhooks/clickup/callback_fixtures/get_task.json create mode 100644 zerver/webhooks/clickup/doc.md create mode 100644 zerver/webhooks/clickup/fixtures/folder_created.json create mode 100644 zerver/webhooks/clickup/fixtures/folder_deleted.json create mode 100644 zerver/webhooks/clickup/fixtures/folder_updated.json create mode 100644 zerver/webhooks/clickup/fixtures/goal_created.json create mode 100644 zerver/webhooks/clickup/fixtures/goal_deleted.json create mode 100644 zerver/webhooks/clickup/fixtures/goal_updated.json create mode 100644 zerver/webhooks/clickup/fixtures/list_created.json create mode 100644 zerver/webhooks/clickup/fixtures/list_deleted.json create mode 100644 zerver/webhooks/clickup/fixtures/list_updated.json create mode 100644 zerver/webhooks/clickup/fixtures/payload_with_spammy_field.json create mode 100644 zerver/webhooks/clickup/fixtures/space_created.json create mode 100644 zerver/webhooks/clickup/fixtures/space_deleted.json create mode 100644 zerver/webhooks/clickup/fixtures/space_updated.json create mode 100644 zerver/webhooks/clickup/fixtures/task_created.json create mode 100644 zerver/webhooks/clickup/fixtures/task_deleted.json create mode 100644 zerver/webhooks/clickup/fixtures/task_moved.json create mode 100644 zerver/webhooks/clickup/fixtures/task_updated_assignee.json create mode 100644 zerver/webhooks/clickup/fixtures/task_updated_comment.json create mode 100644 zerver/webhooks/clickup/fixtures/task_updated_due_date.json create mode 100644 zerver/webhooks/clickup/fixtures/task_updated_priority.json create mode 100644 zerver/webhooks/clickup/fixtures/task_updated_status.json create mode 100644 zerver/webhooks/clickup/fixtures/task_updated_time_estimate.json create mode 100644 zerver/webhooks/clickup/fixtures/task_updated_time_spent.json create mode 100644 zerver/webhooks/clickup/tests.py create mode 100644 zerver/webhooks/clickup/view.py diff --git a/static/images/integrations/bot_avatars/clickup.png b/static/images/integrations/bot_avatars/clickup.png new file mode 100644 index 0000000000000000000000000000000000000000..39197b44d32309074460b48fc98b843dac317221 GIT binary patch literal 3503 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 \ No newline at end of file diff --git a/zerver/lib/integrations.py b/zerver/lib/integrations.py index aa0c61d7597aed..3514fc794c049a 100644 --- a/zerver/lib/integrations.py +++ b/zerver/lib/integrations.py @@ -379,6 +379,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"]), @@ -730,6 +731,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..8b137891791fe9 --- /dev/null +++ b/zerver/webhooks/clickup/__init__.py @@ -0,0 +1 @@ + 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..01d9606b9c383c --- /dev/null +++ b/zerver/webhooks/clickup/doc.md @@ -0,0 +1,88 @@ +# Zulip ClickUp integration + +Get Zulip notifications for your ClickUp space! + +!!! tip "" + + [Zapier](./zapier) is usually a simpler way to integrate ClickUp + with Zulip. + +{start_tabs} + +1. {!create-channel.md!} + +1. {!create-an-incoming-webhook.md!} + +1. {!generate-webhook-url-basic.md!} + +1. Collect your ClickUp **Team ID** by going to your ClickUp home view. + The URL should look like `https://app.clickup.com//home`. + Note down the ``. + +1. Collect your ClickUp **Client ID** and **Client Secret** by following these steps: + + - Go to your [ClickUp API menu][1] and click **Create an App**. + + - You will be prompted for **Redirect URL(s)**, enter the URL for your Zulip organization. + e.g., `{{ zulip_url }}`. + + - Note down the **Client ID** and **Client Secret** + +1. You're now going to need to run a ClickUp configuration script from a + computer (any computer) connected to the internet. It won't make any + changes to the computer. + + 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 + installation instructions [here][2]. + + !!! tip "" + + You do not need the latest version of Python; anything 2.7 or + higher will do. + +1. Download [zulip-clickup.py][3]. + + !!! tip "" + + Ctrl + s or Cmd + s + on that page should work in most browsers. + +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 CLICKUP_TEAM_ID \ + --clickup-client-id CLICKUP_CLIENT_ID \ + --clickup-client-secret CLICKUP_CLIENT_SECRET \ + --zulip-webhook-url "ZULIP_WEBHOOK_URL" + ``` + + !!! warn "" + + **Note**: Make sure that you wrap the webhook URL generated above + in quotes when supplying it on the command-line, as shown above. + +1. Follow the instructions in the terminal and keep an eye on your browser as you + will be redirected to a ClickUp authorization page. + +{end_tabs} + +{!congrats.md!} + +![](/static/images/integrations/clickup/001.png) + +### Related documentation + +- [Zapier ClickUp integration][4] + +{!webhooks-url-specification.md!} + +[1]: https://app.clickup.com/settings/team/clickup-api + +[2]: https://realpython.com/installing-python/ + +[3]: https://raw.githubusercontent.com/zulip/python-zulip-api/main/zulip/integrations/clickup/zulip_clickup.py + +[4]: https://zapier.com/apps/clickup/integrations#zap-template-list 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..290b670327574d --- /dev/null +++ b/zerver/webhooks/clickup/fixtures/list_created.json @@ -0,0 +1,5 @@ +{ + "event": "listCreated", + "list_id": "901601848935", + "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..6f29a35e66265b --- /dev/null +++ b/zerver/webhooks/clickup/fixtures/list_deleted.json @@ -0,0 +1,5 @@ +{ + "event": "listDeleted", + "list_id": "901601848935", + "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..6abe0b0566b012 --- /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": "901601848935", + "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..e4bbdee602d67d --- /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": "86cvyxabb", + "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..331c82832b1a7f --- /dev/null +++ b/zerver/webhooks/clickup/fixtures/space_created.json @@ -0,0 +1,5 @@ +{ + "event": "spaceCreated", + "space_id": "90160869743", + "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..c5d95f29a0b945 --- /dev/null +++ b/zerver/webhooks/clickup/fixtures/space_deleted.json @@ -0,0 +1,5 @@ +{ + "event": "spaceDeleted", + "space_id": "90160869743", + "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..53d9e36468a77b --- /dev/null +++ b/zerver/webhooks/clickup/fixtures/space_updated.json @@ -0,0 +1,5 @@ +{ + "event": "spaceUpdated", + "space_id": "90160869743", + "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..b2ad1abfa2d540 --- /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": "86cvyxabb", + "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..540458df826bf0 --- /dev/null +++ b/zerver/webhooks/clickup/fixtures/task_deleted.json @@ -0,0 +1,5 @@ +{ + "event": "taskDeleted", + "task_id": "86cvyxabb", + "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..46f66c88541eb1 --- /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": "86cvyxabb", + "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..b16d118ecd3f3c --- /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": "86cvyxabb", + "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..d1dd41018e7d9b --- /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": "86cvyxabb", + "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..45610c16c0f10a --- /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": "86cvyxabb", + "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..c5825a6b435807 --- /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": "86cvyxabb", + "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..395ff54cb1a6ab --- /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": "86cvyxabb", + "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..09862ebbb19d86 --- /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": "86cvyxabb", + "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..1b44d6d3c6aea2 --- /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": "86cvyxabb", + "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..8d96b558b29c99 --- /dev/null +++ b/zerver/webhooks/clickup/tests.py @@ -0,0 +1,323 @@ +import json +from typing import Any +from unittest.mock import MagicMock, patch + +from typing_extensions import override + +from zerver.lib.test_classes import WebhookTestCase + +from .api_endpoints import get_clickup_api_data + +EXPECTED_TOPIC = "ClickUp Notification" + + +class ClickUpHookTests(WebhookTestCase): + CHANNEL_NAME = "ClickUp" + URL_TEMPLATE = "/api/v1/external/clickup?api_key={api_key}&stream={stream}&team_id=XXXXXXX&clickup_api_key=123" + FIXTURE_DIR_NAME = "clickup" + WEBHOOK_DIR_NAME = "clickup" + + @override + def setUp(self) -> None: + super().setUp() + self.mock_get_clickup_api_data = patch( + "zerver.webhooks.clickup.view.get_clickup_api_data" + ).start() + self.mock_get_clickup_api_data.side_effect = self.mocked_get_clickup_api_data + + @override + def tearDown(self) -> None: + self.mock_get_clickup_api_data.stop() + super().tearDown() + + def mocked_get_clickup_api_data(self, clickup_api_path: str, **kwargs: Any) -> None: + item = clickup_api_path.split("/")[0] + with open(f"zerver/webhooks/clickup/callback_fixtures/get_{item}.json") as f: + return json.load(f) + + def test_task_created(self) -> None: + expected_message = ( + ":new: **[Task: Tanswer](https://app.clickup.com/XXXXXXX/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, + ) + + 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: + expected_message = ( + "**[Task: Tanswer](https://app.clickup.com/XXXXXXX/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, + ) + + def test_task_updated_time_estimate(self) -> None: + expected_message = ( + "**[Task: Tanswer](https://app.clickup.com/XXXXXXX/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, + ) + + def test_task_updated_comment(self) -> None: + expected_message = ( + "**[Task: Tanswer](https://app.clickup.com/XXXXXXX/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, + ) + + def test_task_moved(self) -> None: + expected_message = ( + "**[Task: Tanswer](https://app.clickup.com/XXXXXXX/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, + ) + + def test_task_updated_assignee(self) -> None: + expected_message = ( + "**[Task: Tanswer](https://app.clickup.com/XXXXXXX/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, + ) + + def test_task_updated_due_date(self) -> None: + expected_message = ( + "**[Task: Tanswer](https://app.clickup.com/XXXXXXX/home)** has been updated!\n" + "~~~ quote\n" + " :spiral_calendar: Due date updated from to \n" + "~~~" + ) + + self.check_webhook( + fixture_name="task_updated_due_date", + expected_topic_name=EXPECTED_TOPIC, + expected_message=expected_message, + ) + + def test_task_updated_priority(self) -> None: + expected_message = ( + "**[Task: Tanswer](https://app.clickup.com/XXXXXXX/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, + ) + + def test_task_updated_status(self) -> None: + expected_message = ( + "**[Task: Tanswer](https://app.clickup.com/XXXXXXX/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, + ) + + def test_list_created(self) -> None: + expected_message = ":new: **[List: Listener](https://app.clickup.com/XXXXXXX/home)** has been created in your ClickUp space!" + self.check_webhook( + fixture_name="list_created", + expected_topic_name=EXPECTED_TOPIC, + expected_message=expected_message, + ) + + def test_list_deleted(self) -> None: + 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: + expected_message = ( + "**[List: Listener](https://app.clickup.com/XXXXXXX/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, + ) + + def test_folder_created(self) -> None: + expected_message = ":new: **[Folder: Lord Foldemort](https://app.clickup.com/XXXXXXX/home)** has been created in your ClickUp space!" + self.check_webhook( + fixture_name="folder_created", + expected_topic_name=EXPECTED_TOPIC, + expected_message=expected_message, + ) + + def test_folder_deleted(self) -> None: + 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: + expected_message = ":new: **[Space: the Milky Way](https://app.clickup.com/XXXXXXX/home)** has been created in your ClickUp space!" + self.check_webhook( + fixture_name="space_created", + expected_topic_name=EXPECTED_TOPIC, + expected_message=expected_message, + ) + + def test_space_deleted(self) -> None: + 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: + expected_message = ( + "**[Space: the Milky Way](https://app.clickup.com/XXXXXXX/home)** has been updated!" + ) + self.check_webhook( + fixture_name="space_updated", + expected_topic_name=EXPECTED_TOPIC, + expected_message=expected_message, + ) + + def test_goal_created(self) -> None: + 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, + ) + + def test_goal_updated(self) -> None: + 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, + ) + + def test_goal_deleted(self) -> None: + 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_payload_with_spammy_field(self) -> None: + expected_message = ( + "**[Task: Tanswer](https://app.clickup.com/XXXXXXX/home)** has been updated!" + ) + self.check_webhook( + fixture_name="payload_with_spammy_field", + expected_topic_name=EXPECTED_TOPIC, + expected_message=expected_message, + ) + + def test_get_clickup_api_data_success_request(self) -> None: + with patch("zerver.webhooks.clickup.api_endpoints.requests.get") as mock_get: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"key123": "value322"} + + mock_get.return_value = mock_response + + result = get_clickup_api_data("list/123123", token="123") + + mock_get.assert_called_once_with( + "https://api.clickup.com/api/v2/list/123123", + headers={ + "Content-Type": "application/json", + "Authorization": "123", + }, + params={}, + ) + self.assertEqual(result, {"key123": "value322"}) + + def test_get_clickup_api_data_failure_request(self) -> None: + with patch("zerver.webhooks.clickup.api_endpoints.requests.get") as mock_get: + mock_response = MagicMock() + mock_response.status_code = 404 + mock_get.return_value = mock_response + + exception_msg = "HTTP error accessing the ClickUp API. Error: 404" + + with self.assertRaisesRegex(Exception, exception_msg): + get_clickup_api_data("list/123123", token="123") + + mock_get.assert_called_once_with( + "https://api.clickup.com/api/v2/list/123123", + headers={ + "Content-Type": "application/json", + "Authorization": "123", + }, + params={}, + ) + + def test_get_clickup_api_data_missing_api_token(self) -> None: + with patch("zerver.webhooks.clickup.api_endpoints.requests"): + exception_msg = "ClickUp API 'token' missing in kwargs" + with self.assertRaisesRegex(AssertionError, exception_msg): + get_clickup_api_data("list/123123", asdasd="123") diff --git a/zerver/webhooks/clickup/view.py b/zerver/webhooks/clickup/view.py new file mode 100644 index 00000000000000..72a61b5101f38a --- /dev/null +++ b/zerver/webhooks/clickup/view.py @@ -0,0 +1,248 @@ +# Webhooks for external integrations. +from typing import Any +from urllib.parse import urljoin + +import requests +from django.http import HttpRequest, HttpResponse + +from zerver.decorator import webhook_view +from zerver.lib.exceptions import UnsupportedWebhookEventTypeError +from zerver.lib.response import json_success +from zerver.lib.typed_endpoint import JsonBodyPayload, typed_endpoint +from zerver.lib.validator import WildValue, check_none_or, check_string +from zerver.lib.webhooks.common import check_send_webhook_message, unix_milliseconds_to_timestamp +from zerver.models import UserProfile + +SIMPLE_FIELDS = ["priority", "status"] + +SPAMMY_FIELDS = ["tag", "tag_removed", "assignee_rem"] + +MESSAGE_WRAPPER = "\n~~~ quote\n {icon} {content}\n~~~\n" + +EVENT_NAME_TEMPLATE: str = "**[{event_item_type}: {event_item_name}]({item_url})**" + + +def parse_event_code(event_code: str) -> tuple[str, str]: + """ + Turns string like "taskUpdated" into ("task", "Updated") + """ + data_list = split_camel_case_string(event_code) + if len(data_list) != 2: + raise UnsupportedWebhookEventTypeError(event_code) + return data_list[0], data_list[1] + + +def split_camel_case_string(string: str) -> list[str]: + words = [] + start_index = 0 + + for i, char in enumerate(string): + if char.isupper() and i > 0: + words.append(string[start_index:i]) + start_index = i + + words.append(string[start_index:]) + + return words + + +def generate_created_event_message(item_data: dict[str, Any], event_item_type: str) -> str: + body = "\n:new: " + EVENT_NAME_TEMPLATE + " has been created in your ClickUp space!" + creator_data = item_data.get("creator") + if isinstance(creator_data, dict) and "username" in creator_data: + # Some payload only doesn't provide users data. + creator_name = creator_data["username"] + body += f"\n - Created by: **{creator_name}**" + return body.format( + event_item_type=event_item_type.title(), + event_item_name=item_data["name"], + item_url=item_data["url"], + ) + + +def generate_updated_event_message( + item_data: dict[str, Any], + event_item_type: str, + payload: WildValue, +) -> str: + body = "\n" + EVENT_NAME_TEMPLATE + " has been updated!" + history_items = payload.get("history_items", []) + + for history_dict in history_items: + updated_field = history_dict["field"].tame(check_string) + if updated_field in SPAMMY_FIELDS: + continue + elif updated_field in SIMPLE_FIELDS: + body += body_message_for_simple_fields(history_dict, event_item_type, updated_field) + else: + body += body_message_for_special_fields(history_dict, 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_fields( + history_dict: WildValue, event_item_type: str, updated_field: str +) -> str: + # The value of "before"/"after" for these payloads maybe a dict or a bool + old_value = ( + history_dict.get("before").get(updated_field).tame(check_string) + if history_dict.get("before") + else None + ) + new_value = ( + history_dict.get("after").get(updated_field).tame(check_string) + if history_dict.get("after") + else None + ) + return MESSAGE_WRAPPER.format( + icon=":note:", + content=f"Updated {event_item_type} {updated_field} from **{old_value}** to **{new_value}**", + ) + + +def body_message_for_special_fields(history_dict: WildValue, updated_field: str) -> str: + event_details = history_dict.get("data", {}) + + if updated_field == "name": + old_value = history_dict["before"].tame(check_none_or(check_string)) + new_value = history_dict["after"].tame(check_none_or(check_string)) + return MESSAGE_WRAPPER.format( + icon=":pencil:", content=f"Renamed from **{old_value}** to **{new_value}**" + ) + elif updated_field == "assignee_add": + new_value = history_dict["after"]["username"].tame(check_string) + return MESSAGE_WRAPPER.format( + icon=":silhouette:", content=f"Now assigned to **{new_value}**" + ) + elif updated_field == "comment": + event_user = history_dict["user"]["username"].tame(check_string) + return MESSAGE_WRAPPER.format( + icon=":speaking_head:", content=f"Commented by **{event_user}**" + ) + elif updated_field == "due_date": + raw_old_due_date = history_dict.get("before").tame(check_none_or(check_string)) + old_due_date = ( + unix_milliseconds_to_timestamp(float(raw_old_due_date), "ClickUp").strftime("%Y-%m-%d") + if raw_old_due_date + else None + ) + raw_new_due_date = history_dict.get("after").tame(check_none_or(check_string)) + new_due_date = ( + unix_milliseconds_to_timestamp(float(raw_new_due_date), "ClickUp").strftime("%Y-%m-%d") + if raw_new_due_date + else None + ) + return MESSAGE_WRAPPER.format( + icon=":spiral_calendar:", + content=f"Due date updated from to ", + ) + elif updated_field == "section_moved": + old_value = history_dict["before"]["name"].tame(check_none_or(check_string)) + new_value = history_dict["after"]["name"].tame(check_none_or(check_string)) + return MESSAGE_WRAPPER.format( + icon=":folder:", content=f"Moved from **{old_value}** to **{new_value}**" + ) + elif updated_field == "time_spent": + raw_time_spent = event_details.get("total_time").tame(check_none_or(check_string)) + new_time_spent = ( + unix_milliseconds_to_timestamp(float(raw_time_spent), "ClickUp").strftime("%H:%M:%S") + if raw_time_spent + else None + ) + return MESSAGE_WRAPPER.format( + icon=":stopwatch:", content=f"Time spent changed to **{new_time_spent}**" + ) + elif updated_field == "time_estimate": + old_value = event_details["old_time_estimate_string"].tame(check_none_or(check_string)) + new_value = event_details["time_estimate_string"].tame(check_none_or(check_string)) + event_user = history_dict["user"]["username"].tame(check_string) + return MESSAGE_WRAPPER.format( + icon=":ruler:", + content=f"Time estimate changed from **{old_value}** to **{new_value}** by **{event_user}**", + ) + else: + raise UnsupportedWebhookEventTypeError(updated_field) + + +def get_clickup_api_data(clickup_api_path: str, **kwargs: Any) -> dict[str, Any]: + if not kwargs.get("token"): + raise AssertionError("ClickUp API 'token' missing in kwargs") + token = kwargs.pop("token") + + base_url = "https://api.clickup.com/api/v2/" + api_endpoint = urljoin(base_url, clickup_api_path) + response = requests.get( + api_endpoint, + headers={ + "Content-Type": "application/json", + "Authorization": token, + }, + params=kwargs, + ) + if response.status_code != requests.codes.ok: + raise Exception(f"HTTP error accessing the ClickUp API. Error: {response.status_code}") + return response.json() + + +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 not in ["task", "list", "folder", "space", "goal"]: + raise UnsupportedWebhookEventTypeError(event_item_type) + + item_id_key = f"{event_item_type}_id" + clickup_api_path = f"{event_item_type}/{payload[item_id_key].tame(check_string)}" + item_data = get_clickup_api_data(clickup_api_path, token=api_key) + + if event_item_type == "goal": + # The data for "goal" is nested one level deeper. + item_data = item_data["goal"] + + item_data["url"] = item_data.get("pretty_url", f"https://app.clickup.com/{team_id}/home") + + return item_data + + +@webhook_view("ClickUp") +@typed_endpoint +def api_clickup_webhook( + request: HttpRequest, + user_profile: UserProfile, + *, + payload: JsonBodyPayload[WildValue], + clickup_api_key: str, + team_id: str, +) -> HttpResponse: + event_code = payload["event"].tame(check_string) + event_item_type, event_action = parse_event_code(event_code=event_code) + topic = "ClickUp Notification" + + if event_action == "Deleted": + body = ( + f"\n:trash_can: A {event_item_type.title()} has been deleted from your ClickUp space!" + ) + check_send_webhook_message(request, user_profile, topic, body) + return json_success(request) + + item_data = get_item_data( + event_item_type, + clickup_api_key, + payload, + team_id, + ) + + if event_action == "Created": + body = generate_created_event_message(item_data, event_item_type) + elif event_action == "Updated": + body = generate_updated_event_message(item_data, event_item_type, payload) + else: + raise UnsupportedWebhookEventTypeError(event_code) + + check_send_webhook_message(request, user_profile, topic, body) + return json_success(request)