From 15c479dec4d50283ee98887ecffbbc278d76902d Mon Sep 17 00:00:00 2001 From: PieterCK Date: Tue, 2 Apr 2024 03:40:04 +0000 Subject: [PATCH] integrations: Add ClickUp integration. Creates an incoming webhook integration for ClickUp. The main use case is getting notifications when new ClickUp items such as task, list, folder, space, goals are created, updated or deleted. Fixes zulip#26529. --- static/images/integrations/clickup/002.png | Bin 0 -> 28278 bytes zerver/lib/integrations.py | 3 +- zerver/webhooks/clickup/__init__.py | 45 ++++++ zerver/webhooks/clickup/api_endpoints.py | 74 +++++++-- .../clickup/callback_fixtures/get_folder.json | 24 +-- .../clickup/callback_fixtures/get_goal.json | 60 +++---- .../clickup/callback_fixtures/get_list.json | 50 +++--- .../clickup/callback_fixtures/get_space.json | 86 +++++------ .../clickup/callback_fixtures/get_task.json | 70 ++++----- zerver/webhooks/clickup/doc.md | 59 +++---- zerver/webhooks/clickup/tests.py | 146 +++++++++++++++++- zerver/webhooks/clickup/view.py | 109 +++---------- 12 files changed, 438 insertions(+), 288 deletions(-) create mode 100644 static/images/integrations/clickup/002.png diff --git a/static/images/integrations/clickup/002.png b/static/images/integrations/clickup/002.png new file mode 100644 index 0000000000000000000000000000000000000000..c2f51c0c0982e58b9b5c53c60c079a6269adbe68 GIT binary patch literal 28278 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 None: ScreenshotConfig("bitbucket_job_completed.json", image_name="001.png"), ScreenshotConfig("github_job_completed.json", image_name="002.png"), ], - "clickup": [ScreenshotConfig("task_created.json")], + "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 index e69de29bb2d1d6..e3acaa089e4ac8 100644 --- a/zerver/webhooks/clickup/__init__.py +++ b/zerver/webhooks/clickup/__init__.py @@ -0,0 +1,45 @@ +from enum import Enum +from typing import List + + +class ConstantVariable(Enum): + @classmethod + def as_list(cls) -> List[str]: + return [item.value for item in cls] + + +class EventItemType(ConstantVariable): + TASK: str = "task" + LIST: str = "list" + FOLDER: str = "folder" + GOAL: str = "goal" + SPACE: str = "space" + + +class EventAcion(ConstantVariable): + CREATED: str = "Created" + UPDATED: str = "Updated" + DELETED: str = "Deleted" + + +class SimpleFields(ConstantVariable): + # Events with identical payload format + PRIORITY: str = "priority" + STATUS: str = "status" + + +class SpecialFields(ConstantVariable): + # Event with unique payload + NAME: str = "name" + ASSIGNEE: str = "assignee_add" + COMMENT: str = "comment" + DUE_DATE: str = "due_date" + MOVED: str = "section_moved" + TIME_ESTIMATE: str = "time_estimate" + TIME_SPENT: str = "time_spent" + + +class SpammyFields(ConstantVariable): + TAG: str = "tag" + TAG_REMOVED: str = "tag_removed" + UNASSIGN: str = "assignee_rem" diff --git a/zerver/webhooks/clickup/api_endpoints.py b/zerver/webhooks/clickup/api_endpoints.py index a74ab91ea70811..36ec6877b9f9fd 100644 --- a/zerver/webhooks/clickup/api_endpoints.py +++ b/zerver/webhooks/clickup/api_endpoints.py @@ -1,28 +1,78 @@ -from typing import Any, Dict +import re +from typing import Any, Dict, Optional, Union +from urllib.parse import urljoin import requests -from urllib.parse import urljoin +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"] -class Error(Exception): - pass + 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 APIUnavailableError(Error): - pass +class BadRequestCallBackError(WebhookError): + """Intended as an exception for when an integration + makes a bad request to external API server. -class BadRequestError(Error): - pass + 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) + 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, @@ -35,10 +85,10 @@ def make_clickup_request(path: str, api_key: str) -> Dict[str, Any]: api_endpoint, ) response.raise_for_status() - except (requests.ConnectionError, requests.Timeout) as e: - raise APIUnavailableError from e + except (requests.ConnectionError, requests.Timeout): + raise APIUnavailableCallBackError except requests.HTTPError as e: - raise BadRequestError from e + raise BadRequestCallBackError(e.response.status_code) return response.json() diff --git a/zerver/webhooks/clickup/callback_fixtures/get_folder.json b/zerver/webhooks/clickup/callback_fixtures/get_folder.json index 6f873ac0ec94d1..f646c9fdac0520 100644 --- a/zerver/webhooks/clickup/callback_fixtures/get_folder.json +++ b/zerver/webhooks/clickup/callback_fixtures/get_folder.json @@ -1,14 +1,14 @@ { -"id": "457", -"name": "Lord Foldemort", -"orderindex": 0, -"override_statuses": false, -"hidden": false, -"space": { - "id": "789", - "name": "Space Name", - "access": true -}, -"task_count": "0", -"lists": [] + "id": "457", + "name": "Lord Foldemort", + "orderindex": 0, + "override_statuses": false, + "hidden": false, + "space": { + "id": "789", + "name": "Space Name", + "access": true + }, + "task_count": "0", + "lists": [] } diff --git a/zerver/webhooks/clickup/callback_fixtures/get_goal.json b/zerver/webhooks/clickup/callback_fixtures/get_goal.json index afcf3af54b617c..733317c1e2be02 100644 --- a/zerver/webhooks/clickup/callback_fixtures/get_goal.json +++ b/zerver/webhooks/clickup/callback_fixtures/get_goal.json @@ -1,33 +1,33 @@ { "goal": { - "id": "e53a033c-900e-462d-a849-4a216b06d930", - "name": "hat-trick", - "team_id": "512", - "date_created": "1568044355026", - "start_date": null, - "due_date": "1568036964079", - "description": "Updated Goal Description", - "private": false, - "archived": false, - "creator": 183, - "color": "#32a852", - "pretty_id": "6", - "multiple_owners": true, - "folder_id": null, - "members": [], - "owners": [ - { - "id": 182, - "username": "Pieter CK", - "email": "kwok.pieter@gmail.com", - "color": "#7b68ee", - "initials": "PK", - "profilePicture": "https://attachments-public.clickup.com/profilePictures/182_abc.jpg" - } - ], - "key_results": [], - "percent_completed": 0, - "history": [], - "pretty_url": "https://app.clickup.com/512/goals/6" + "id": "e53a033c-900e-462d-a849-4a216b06d930", + "name": "hat-trick", + "team_id": "512", + "date_created": "1568044355026", + "start_date": null, + "due_date": "1568036964079", + "description": "Updated Goal Description", + "private": false, + "archived": false, + "creator": 183, + "color": "#32a852", + "pretty_id": "6", + "multiple_owners": true, + "folder_id": null, + "members": [], + "owners": [ + { + "id": 182, + "username": "Pieter CK", + "email": "kwok.pieter@gmail.com", + "color": "#7b68ee", + "initials": "PK", + "profilePicture": "https://attachments-public.clickup.com/profilePictures/182_abc.jpg" + } + ], + "key_results": [], + "percent_completed": 0, + "history": [], + "pretty_url": "https://app.clickup.com/512/goals/6" } - } +} diff --git a/zerver/webhooks/clickup/callback_fixtures/get_list.json b/zerver/webhooks/clickup/callback_fixtures/get_list.json index 657f3cd1d6bf27..b23ba40f55f3ce 100644 --- a/zerver/webhooks/clickup/callback_fixtures/get_list.json +++ b/zerver/webhooks/clickup/callback_fixtures/get_list.json @@ -4,13 +4,13 @@ "orderindex": 1, "content": "Updated List Content", "status": { - "status": "red", - "color": "#e50000", - "hide_label": true + "status": "red", + "color": "#e50000", + "hide_label": true }, "priority": { - "priority": "high", - "color": "#f50000" + "priority": "high", + "color": "#f50000" }, "assignee": null, "due_date": "1567780450202", @@ -18,32 +18,32 @@ "start_date": null, "start_date_time": null, "folder": { - "id": "456", - "name": "Folder Name", - "hidden": false, - "access": true + "id": "456", + "name": "Folder Name", + "hidden": false, + "access": true }, "space": { - "id": "789", - "name": "Space Name", - "access": true + "id": "789", + "name": "Space Name", + "access": true }, "inbound_address": "add.task.124.ac725f.31518a6a-05bb-4997-92a6-1dcfe2f527ca@tasks.clickup.com", "archived": false, "override_statuses": false, "statuses": [ - { - "status": "to do", - "orderindex": 0, - "color": "#d3d3d3", - "type": "open" - }, - { - "status": "complete", - "orderindex": 1, - "color": "#6bc950", - "type": "closed" - } + { + "status": "to do", + "orderindex": 0, + "color": "#d3d3d3", + "type": "open" + }, + { + "status": "complete", + "orderindex": 1, + "color": "#6bc950", + "type": "closed" + } ], "permission_level": "create" - } +} diff --git a/zerver/webhooks/clickup/callback_fixtures/get_space.json b/zerver/webhooks/clickup/callback_fixtures/get_space.json index e12d535cdb0812..d19af504b23fb4 100644 --- a/zerver/webhooks/clickup/callback_fixtures/get_space.json +++ b/zerver/webhooks/clickup/callback_fixtures/get_space.json @@ -3,50 +3,50 @@ "name": "the Milky Way", "private": false, "statuses": [ - { - "status": "to do", - "type": "open", - "orderindex": 0, - "color": "#d3d3d3" - }, - { - "status": "complete", - "type": "closed", - "orderindex": 1, - "color": "#6bc950" - } + { + "status": "to do", + "type": "open", + "orderindex": 0, + "color": "#d3d3d3" + }, + { + "status": "complete", + "type": "closed", + "orderindex": 1, + "color": "#6bc950" + } ], "multiple_assignees": false, "features": { - "due_dates": { - "enabled": false, - "start_date": false, - "remap_due_dates": false, - "remap_closed_due_date": false - }, - "time_tracking": { - "enabled": false - }, - "tags": { - "enabled": false - }, - "time_estimates": { - "enabled": false - }, - "checklists": { - "enabled": true - }, - "custom_fields": { - "enabled": true - }, - "remap_dependencies": { - "enabled": false - }, - "dependency_warning": { - "enabled": false - }, - "portfolios": { - "enabled": false - } + "due_dates": { + "enabled": false, + "start_date": false, + "remap_due_dates": false, + "remap_closed_due_date": false + }, + "time_tracking": { + "enabled": false + }, + "tags": { + "enabled": false + }, + "time_estimates": { + "enabled": false + }, + "checklists": { + "enabled": true + }, + "custom_fields": { + "enabled": true + }, + "remap_dependencies": { + "enabled": false + }, + "dependency_warning": { + "enabled": false + }, + "portfolios": { + "enabled": false + } } - } +} diff --git a/zerver/webhooks/clickup/callback_fixtures/get_task.json b/zerver/webhooks/clickup/callback_fixtures/get_task.json index 3284ac99ff1c57..146db98e6ad868 100644 --- a/zerver/webhooks/clickup/callback_fixtures/get_task.json +++ b/zerver/webhooks/clickup/callback_fixtures/get_task.json @@ -6,30 +6,24 @@ "text_content": "string", "description": "string", "status": { - "status": "in progress", - "color": "#d3d3d3", - "orderindex": 1, - "type": "custom" + "status": "in progress", + "color": "#d3d3d3", + "orderindex": 1, + "type": "custom" }, "orderindex": "string", "date_created": "string", "date_updated": "string", "date_closed": "string", "creator": { - "id": 183, - "username": "Pieter CK", - "color": "#827718", - "profilePicture": "https://attachments-public.clickup.com/profilePictures/183_abc.jpg" + "id": 183, + "username": "Pieter CK", + "color": "#827718", + "profilePicture": "https://attachments-public.clickup.com/profilePictures/183_abc.jpg" }, - "assignees": [ - "string" - ], - "checklists": [ - "string" - ], - "tags": [ - "string" - ], + "assignees": ["string"], + "checklists": ["string"], + "tags": ["string"], "parent": "string", "priority": "string", "due_date": "string", @@ -37,33 +31,33 @@ "time_estimate": "string", "time_spent": "string", "custom_fields": [ - { - "id": "string", - "name": "string", - "type": "string", - "type_config": {}, - "date_created": "string", - "hide_from_guests": true, - "value": { - "id": 183, - "username": "Pieter CK", - "email": "kwok.pieter@gmail.com", - "color": "#7b68ee", - "initials": "PK", - "profilePicture": null - }, - "required": true - } + { + "id": "string", + "name": "string", + "type": "string", + "type_config": {}, + "date_created": "string", + "hide_from_guests": true, + "value": { + "id": 183, + "username": "Pieter CK", + "email": "kwok.pieter@gmail.com", + "color": "#7b68ee", + "initials": "PK", + "profilePicture": null + }, + "required": true + } ], "list": { - "id": "123" + "id": "123" }, "folder": { - "id": "456" + "id": "456" }, "space": { - "id": "789" + "id": "789" }, "url": "https://app.clickup.com/XXXXXXXX/home", "markdown_description": "string" - } +} diff --git a/zerver/webhooks/clickup/doc.md b/zerver/webhooks/clickup/doc.md index cd29c5744c970e..348f991aa7e761 100644 --- a/zerver/webhooks/clickup/doc.md +++ b/zerver/webhooks/clickup/doc.md @@ -1,48 +1,38 @@ +# Zulip ClickUp integration !!! tip "" Note that [Zapier][1] is usually a simpler way to integrate ClickUp with Zulip. -Get Zulip notifications from your ClickUp space! +Get Zulip notifications for your ClickUp space! [1]: ./zapier - +{start_tabs} 1. {!create-stream.md!} 1. {!create-an-incoming-webhook.md!} -1. {!generate-integration-url.md!} - - You're now going to need to run a ClickUp Integration configuration script from a - computer (any computer) connected to the internet. It won't make any - changes to the computer. - -1. Make sure you have a working copy of Python. If you're running - macOS or Linux, you very likely already do. If you're running - Windows you may or may not. If you don't have Python, follow the - installation instructions - [here](https://realpython.com/installing-python/). Note that you - do not need the latest version of Python; anything 2.7 or higher - will do. +1. {!generate-webhook-url-basic.md!} + 1. Download [zulip-clickup.py][2]. `Ctrl+s` or `Cmd+s` on that page should work in most browsers. -1. To run the script, you require the following 3 items: +1. Make sure you have a working copy of [Python](https://realpython.com/installing-python/), you will need it to run the script later. - * **Team ID**: Go to your ClickUp home. The URL should look like - `https://app.clickup.com//home`. Note down the - ``. +1. Collect your ClickUp **Team ID**: + * Go to your ClickUp home. The URL should look like `https://app.clickup.com//home`. Note down the + ``. - * **Client ID & Client Secret**: Please follow the instructions below: - - - Go to and click **Create an App** button. - - After that, you will be prompted for Redirect URL(s). You must enter your zulip app URL. - e.g. `YourZulipApp.com`. +1. Collect your ClickUp **Client ID & Client Secret** : + 1. Go to and click **Create an App** button. - - Finally, note down the **Client ID** and **Client Secret** + 1. You will be prompted for Redirect URL(s). Enter your zulip app URL. + e.g. `YourZulipApp.com`. -1. Run the `zulip-clickup` script in a terminal, after replacing the all caps + 1. Finally, note down the **Client ID** and **Client Secret** + +1. Run the `zulip-clickup.py` script in a terminal, after replacing the all caps arguments with the values collected above. ``` @@ -50,18 +40,17 @@ Get Zulip notifications from your ClickUp space! --clickup-client-id CLIENT_ID \ --clickup-client-secret CLIENT_SECRET ``` - - The `zulip-clickup.py` script only needs to be run once, and can be run - on any computer with python. -1. Follow the instructions given by the script. - **Note:** You will be prompted for the **integration url** you just generated in step 2 and watch your browser since you will be redirected to a ClickUp authorization page to proceed. +1. Follow the instructions given by the script. -1. You can delete `zulip-clickup.py` from your computer if you'd like or run it again to - reconfigure your ClickUp integration. + **Note**: keep watch for your browser, you will be redirected to ClickUp authorization page +{end_tabs} -[2]: https://raw.githubusercontent.com/zulip/python-zulip-api/main/zulip/integrations/trello/zulip_trello.py +[2]: https://raw.githubusercontent.com/zulip/python-zulip-api/main/zulip/integrations/clickup/zulip_clickup.py {!congrats.md!} -![](/static/images/integrations/clickup/001.png) +![](/static/images/integrations/clickup/002.png) +### Related documentation + +{!webhooks-url-specification.md!} diff --git a/zerver/webhooks/clickup/tests.py b/zerver/webhooks/clickup/tests.py index 743c6d73a9627c..95f9b7cfdbd6d5 100644 --- a/zerver/webhooks/clickup/tests.py +++ b/zerver/webhooks/clickup/tests.py @@ -1,7 +1,25 @@ -from zerver.lib.test_classes import WebhookTestCase -from unittest.mock import patch - 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" @@ -341,3 +359,125 @@ def test_goal_deleted(self) -> None: expected_topic_name=EXPECTED_TOPIC, expected_message=expected_message, ) + + def test_missing_request_variable(self) -> None: + self.url = self.build_webhook_url() + exception_msg = "Missing 'clickup_api_key' argument" + with self.assertRaisesRegex(AssertionError, exception_msg): + expected_message = ":trash_can: A Goal has been deleted from your ClickUp space!" + self.check_webhook( + fixture_name="goal_deleted", + expected_topic_name=EXPECTED_TOPIC, + expected_message=expected_message, + ) + + def test_webhook_api_callback_unavailable_error(self) -> None: + @webhook_view("ClientName") + def my_webhook_raises_exception( + request: HttpRequest, user_profile: UserProfile + ) -> HttpResponse: + raise APIUnavailableCallBackError + + request = HostRequestMock() + request.method = "POST" + request.host = "zulip.testserver" + + request._body = b"{}" + request.content_type = "text/plain" + request.POST["api_key"] = get_api_key(self.example_user("hamlet")) + exception_msg = "ClientName integration couldn't reach an external API service; ignoring" + with patch( + "zerver.decorator.webhook_logger.exception" + ) as mock_exception, self.assertRaisesRegex(APIUnavailableCallBackError, exception_msg): + my_webhook_raises_exception(request) + mock_exception.assert_called_once() + self.assertIsInstance(mock_exception.call_args.args[0], APIUnavailableCallBackError) + self.assertEqual(mock_exception.call_args.args[0].msg, exception_msg) + self.assertEqual( + mock_exception.call_args.kwargs, {"extra": {"request": request}, "stack_info": True} + ) + + def test_webhook_api_callback_bad_request_error(self) -> None: + @webhook_view(webhook_client_name="ClientName") + def my_webhook_raises_exception( + request: HttpRequest, user_profile: UserProfile + ) -> HttpResponse: + raise BadRequestCallBackError("") + + request = HostRequestMock() + request.method = "POST" + request.host = "zulip.testserver" + + request._body = b"{}" + request.content_type = "text/plain" + request.POST["api_key"] = get_api_key(self.example_user("hamlet")) + exception_msg = ( + "ClientName integration tries to make a bad outgoing request: ; ignoring" + ) + with patch( + "zerver.decorator.webhook_logger.exception" + ) as mock_exception, self.assertRaisesRegex(BadRequestCallBackError, exception_msg): + my_webhook_raises_exception(request) + mock_exception.assert_called_once() + self.assertIsInstance(mock_exception.call_args.args[0], BadRequestCallBackError) + self.assertEqual(mock_exception.call_args.args[0].msg, exception_msg) + self.assertEqual( + mock_exception.call_args.kwargs, {"extra": {"request": request}, "stack_info": True} + ) + + def test_verify_url_path(self) -> None: + invalid_paths = ["oauth/token", "user", "webhook"] + for path in invalid_paths: + with self.assertRaises(BadRequestCallBackError): + make_clickup_request(path, api_key="123") + + def test_clickup_request_http_error(self) -> None: + with patch("zerver.webhooks.clickup.api_endpoints.ClickUpSession") as mock_clickup_session: + mock_response = MagicMock() + mock_response.status_code = 404 + mock_clickup_session.return_value.get.side_effect = HTTPError(response=mock_response) + with self.assertRaises(BadRequestCallBackError): + make_clickup_request("list/123123", api_key="123") + mock_clickup_session.return_value.get.assert_called_once() + + def test_clickup_request_connection_error(self) -> None: + with patch("zerver.webhooks.clickup.api_endpoints.ClickUpSession") as mock_clickup_session: + mock_response = MagicMock() + mock_clickup_session.return_value.get.side_effect = ConnectionError( + response=mock_response + ) + with self.assertRaises(APIUnavailableCallBackError): + make_clickup_request("list/123123", api_key="123") + mock_clickup_session.return_value.get.assert_called_once() + + def test_clickup_request_timeout_error(self) -> None: + with patch("zerver.webhooks.clickup.api_endpoints.ClickUpSession") as mock_clickup_session: + mock_response = MagicMock() + mock_clickup_session.return_value.get.side_effect = Timeout(response=mock_response) + with self.assertRaises(APIUnavailableCallBackError): + make_clickup_request("list/123123", api_key="123") + mock_clickup_session.return_value.get.assert_called_once() + + def test_clickup_api_endpoints(self) -> None: + endpoint_map: Dict[str, Callable[[str, str], Dict[str, Any]]] = { + "folder": get_folder, + "list": get_list, + "space": get_space, + "task": get_task, + "goal": get_goal, + } + for item, call_api in endpoint_map.items(): + mock_fixtures_path = f"zerver/webhooks/clickup/callback_fixtures/get_{item}.json" + with patch( + "zerver.webhooks.clickup.api_endpoints.ClickUpSession" + ) as mock_clickup_session, open(mock_fixtures_path) as f: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.raise_for_status.side_effect = None + item_fixture = json.load(f) + mock_response.json.return_value = item_fixture + mock_clickup_session.return_value.get.return_value = mock_response + item_data = call_api("123", "XXXX") + + self.assertDictEqual(item_data, item_fixture) + mock_clickup_session.return_value.get.assert_called_once() diff --git a/zerver/webhooks/clickup/view.py b/zerver/webhooks/clickup/view.py index 507f0b6644ff49..12a83e0ef01e28 100644 --- a/zerver/webhooks/clickup/view.py +++ b/zerver/webhooks/clickup/view.py @@ -1,78 +1,30 @@ # Webhooks for external integrations. -from enum import Enum -from typing import Any, Dict, List, Tuple - -from django.http import HttpRequest, HttpResponse - import logging import re +from typing import Any, Dict, Tuple + +from django.http import HttpRequest, HttpResponse from zerver.decorator import webhook_view from zerver.lib.exceptions import UnsupportedWebhookEventTypeError -from zerver.lib.request import REQ, RequestVariableMissingError, has_request_variables +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 .api_endpoints import ( - APIUnavailableError, - BadRequestError, - get_folder, - get_goal, - get_list, - get_space, - get_task, +from zerver.webhooks.clickup import ( + EventAcion, + EventItemType, + SimpleFields, + SpammyFields, + SpecialFields, ) +from .api_endpoints import get_folder, get_goal, get_list, get_space, get_task logger = logging.getLogger(__name__) - -class ConstantVariable(Enum): - @classmethod - def as_list(cls) -> List[str]: - return [item.value for item in cls] - - -class EventItemType(ConstantVariable): - TASK: str = "task" - LIST: str = "list" - FOLDER: str = "folder" - GOAL: str = "goal" - SPACE: str = "space" - - -class EventAcion(ConstantVariable): - CREATED: str = "Created" - UPDATED: str = "Updated" - DELETED: str = "Deleted" - - -class SimpleFields(ConstantVariable): - # Events with identical payload format - PRIORITY: str = "priority" - STATUS: str = "status" - - -class SpecialFields(ConstantVariable): - # Event with unique payload - NAME: str = "name" - ASSIGNEE: str = "assignee_add" - COMMENT: str = "comment" - DUE_DATE: str = "due_date" - MOVED: str = "section_moved" - TIME_ESTIMATE: str = "time_estimate" - TIME_SPENT: str = "time_spent" - - -class SpammyFields(ConstantVariable): - TAG: str = "tag" - TAG_REMOVED: str = "tag_removed" - UNASSIGN: str = "assignee_rem" - - EVENT_NAME_TEMPLATE: str = "**[{event_item_type}: {event_item_name}]({item_url})**" @@ -87,23 +39,9 @@ def api_clickup_webhook( *, payload: JsonBodyPayload[WildValue], ) -> HttpResponse: - if not team_id: - raise RequestVariableMissingError("team_id") - if not clickup_api_key: - raise RequestVariableMissingError("clickup_api_key") - - try: - topic, body = topic_and_body(payload, clickup_api_key, team_id) - except APIUnavailableError: - logger.warning("Could not reach ClickUp API.") - except BadRequestError: - logger.warning("ClickUp service had internal error") - except Exception as e: - logger.warning(e) - else: - check_send_webhook_message(request, user_profile, topic, body) - finally: - return json_success(request) + topic, body = topic_and_body(payload, clickup_api_key, team_id) + check_send_webhook_message(request, user_profile, topic, body) + return json_success(request) def topic_and_body(payload: WildValue, clickup_api_key: str, team_id: str) -> Tuple[str, str]: @@ -178,8 +116,8 @@ def generate_updated_event_message( for history_data in history_items: updated_field = history_data["field"].tame(check_string) if updated_field in SpammyFields.as_list(): - # Updating these fields may trigger multiple notifications at a time. - raise UnsupportedWebhookEventTypeError(updated_field) + # Updating these fields may trigger multiple identical notifications at a time. + continue # nocoverage elif updated_field in SimpleFields.as_list(): body += body_message_for_simple_field( history_data=history_data, event_item_type=event_item_type @@ -208,14 +146,7 @@ def body_message_for_simple_field(history_data: WildValue, event_item_type: str) if history_data.get("after") else None ) - return ( - "\n~~~ quote\n :note: Updated {event_item_type} {updated_field} from **{old_value}** to **{new_value}**\n~~~\n" - ).format( - event_item_type=event_item_type, - updated_field=updated_field, - old_value=old_value, - new_value=new_value, - ) + return f"\n~~~ quote\n :note: Updated {event_item_type} {updated_field} from **{old_value}** to **{new_value}**\n~~~\n" def body_message_for_special_field(history_data: WildValue) -> str: @@ -304,9 +235,9 @@ def get_item_data( goal_data: Dict[str, Any] = get_goal( api_key=api_key, goal_id=payload["goal_id"].tame(check_string) ) - item_data.update( - goal_data.get("goal") - ) # in case of Goal payload, useful datas are stored 1 level deeper + 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: