Skip to content

Commit 69ca20f

Browse files
committed
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.
1 parent f40fba2 commit 69ca20f

File tree

11 files changed

+419
-259
lines changed

11 files changed

+419
-259
lines changed

zerver/lib/integrations.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from django.utils.translation import gettext_lazy
99
from django_stubs_ext import StrPromise
1010
from typing_extensions import TypeAlias
11+
1112
from zerver.lib.storage import static_path
1213

1314
"""This module declares all of the (documented) integrations available

zerver/webhooks/clickup/__init__.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from enum import Enum
2+
from typing import List
3+
4+
5+
class ConstantVariable(Enum):
6+
@classmethod
7+
def as_list(cls) -> List[str]:
8+
return [item.value for item in cls]
9+
10+
11+
class EventItemType(ConstantVariable):
12+
TASK: str = "task"
13+
LIST: str = "list"
14+
FOLDER: str = "folder"
15+
GOAL: str = "goal"
16+
SPACE: str = "space"
17+
18+
19+
class EventAcion(ConstantVariable):
20+
CREATED: str = "Created"
21+
UPDATED: str = "Updated"
22+
DELETED: str = "Deleted"
23+
24+
25+
class SimpleFields(ConstantVariable):
26+
# Events with identical payload format
27+
PRIORITY: str = "priority"
28+
STATUS: str = "status"
29+
30+
31+
class SpecialFields(ConstantVariable):
32+
# Event with unique payload
33+
NAME: str = "name"
34+
ASSIGNEE: str = "assignee_add"
35+
COMMENT: str = "comment"
36+
DUE_DATE: str = "due_date"
37+
MOVED: str = "section_moved"
38+
TIME_ESTIMATE: str = "time_estimate"
39+
TIME_SPENT: str = "time_spent"
40+
41+
42+
class SpammyFields(ConstantVariable):
43+
TAG: str = "tag"
44+
TAG_REMOVED: str = "tag_removed"
45+
UNASSIGN: str = "assignee_rem"

zerver/webhooks/clickup/api_endpoints.py

Lines changed: 62 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,78 @@
1-
from typing import Any, Dict
1+
import re
2+
from typing import Any, Dict, Optional, Union
3+
from urllib.parse import urljoin
24

35
import requests
4-
from urllib.parse import urljoin
6+
from django.utils.translation import gettext as _
7+
from typing_extensions import override
8+
9+
from zerver.lib.exceptions import ErrorCode, WebhookError
510
from zerver.lib.outgoing_http import OutgoingSession
11+
from zerver.webhooks.clickup import EventItemType
12+
13+
14+
class APIUnavailableCallBackError(WebhookError):
15+
"""Intended as an exception for when an integration
16+
couldn't reach external API server when calling back
17+
from Zulip app.
18+
19+
Exception when callback request has timed out or received
20+
connection error.
21+
"""
622

23+
code = ErrorCode.REQUEST_TIMEOUT
24+
http_status_code = 200
25+
data_fields = ["webhook_name"]
726

8-
class Error(Exception):
9-
pass
27+
def __init__(self) -> None:
28+
super().__init__()
1029

30+
@staticmethod
31+
@override
32+
def msg_format() -> str:
33+
return _("{webhook_name} integration couldn't reach an external API service; ignoring")
1134

12-
class APIUnavailableError(Error):
13-
pass
1435

36+
class BadRequestCallBackError(WebhookError):
37+
"""Intended as an exception for when an integration
38+
makes a bad request to external API server.
1539
16-
class BadRequestError(Error):
17-
pass
40+
Exception when callback request has an invalid format.
41+
"""
42+
43+
code = ErrorCode.BAD_REQUEST
44+
http_status_code = 200
45+
data_fields = ["webhook_name", "error_detail"]
46+
47+
def __init__(self, error_detail: Optional[Union[str, int]]) -> None:
48+
super().__init__()
49+
self.error_detail = error_detail
50+
51+
@staticmethod
52+
@override
53+
def msg_format() -> str:
54+
return _(
55+
"{webhook_name} integration tries to make a bad outgoing request: {error_detail}; ignoring"
56+
)
1857

1958

2059
class ClickUpSession(OutgoingSession):
2160
def __init__(self, **kwargs: Any) -> None:
22-
super().__init__(role="clickup", timeout=5, **kwargs)
61+
super().__init__(role="clickup", timeout=5, **kwargs) # nocoverage
62+
63+
64+
def verify_url_path(path: str) -> bool:
65+
parts = path.split("/")
66+
if len(parts) < 2 or parts[0] not in EventItemType.as_list() or parts[1] == "":
67+
return False
68+
pattern = r"^[a-zA-Z0-9_-]+$"
69+
match = re.match(pattern, parts[1])
70+
return match is not None and match.group() == parts[1]
2371

2472

2573
def make_clickup_request(path: str, api_key: str) -> Dict[str, Any]:
74+
if verify_url_path(path) is False:
75+
raise BadRequestCallBackError("Invalid path")
2676
headers: Dict[str, str] = {
2777
"Content-Type": "application/json",
2878
"Authorization": api_key,
@@ -35,10 +85,10 @@ def make_clickup_request(path: str, api_key: str) -> Dict[str, Any]:
3585
api_endpoint,
3686
)
3787
response.raise_for_status()
38-
except (requests.ConnectionError, requests.Timeout) as e:
39-
raise APIUnavailableError from e
88+
except (requests.ConnectionError, requests.Timeout):
89+
raise APIUnavailableCallBackError
4090
except requests.HTTPError as e:
41-
raise BadRequestError from e
91+
raise BadRequestCallBackError(e.response.status_code)
4292

4393
return response.json()
4494

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
{
2-
"id": "457",
3-
"name": "Lord Foldemort",
4-
"orderindex": 0,
5-
"override_statuses": false,
6-
"hidden": false,
7-
"space": {
8-
"id": "789",
9-
"name": "Space Name",
10-
"access": true
11-
},
12-
"task_count": "0",
13-
"lists": []
2+
"id": "457",
3+
"name": "Lord Foldemort",
4+
"orderindex": 0,
5+
"override_statuses": false,
6+
"hidden": false,
7+
"space": {
8+
"id": "789",
9+
"name": "Space Name",
10+
"access": true
11+
},
12+
"task_count": "0",
13+
"lists": []
1414
}
Lines changed: 30 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,33 @@
11
{
22
"goal": {
3-
"id": "e53a033c-900e-462d-a849-4a216b06d930",
4-
"name": "hat-trick",
5-
"team_id": "512",
6-
"date_created": "1568044355026",
7-
"start_date": null,
8-
"due_date": "1568036964079",
9-
"description": "Updated Goal Description",
10-
"private": false,
11-
"archived": false,
12-
"creator": 183,
13-
"color": "#32a852",
14-
"pretty_id": "6",
15-
"multiple_owners": true,
16-
"folder_id": null,
17-
"members": [],
18-
"owners": [
19-
{
20-
"id": 182,
21-
"username": "Pieter CK",
22-
"email": "[email protected]",
23-
"color": "#7b68ee",
24-
"initials": "PK",
25-
"profilePicture": "https://attachments-public.clickup.com/profilePictures/182_abc.jpg"
26-
}
27-
],
28-
"key_results": [],
29-
"percent_completed": 0,
30-
"history": [],
31-
"pretty_url": "https://app.clickup.com/512/goals/6"
3+
"id": "e53a033c-900e-462d-a849-4a216b06d930",
4+
"name": "hat-trick",
5+
"team_id": "512",
6+
"date_created": "1568044355026",
7+
"start_date": null,
8+
"due_date": "1568036964079",
9+
"description": "Updated Goal Description",
10+
"private": false,
11+
"archived": false,
12+
"creator": 183,
13+
"color": "#32a852",
14+
"pretty_id": "6",
15+
"multiple_owners": true,
16+
"folder_id": null,
17+
"members": [],
18+
"owners": [
19+
{
20+
"id": 182,
21+
"username": "Pieter CK",
22+
"email": "[email protected]",
23+
"color": "#7b68ee",
24+
"initials": "PK",
25+
"profilePicture": "https://attachments-public.clickup.com/profilePictures/182_abc.jpg"
26+
}
27+
],
28+
"key_results": [],
29+
"percent_completed": 0,
30+
"history": [],
31+
"pretty_url": "https://app.clickup.com/512/goals/6"
3232
}
33-
}
33+
}

zerver/webhooks/clickup/callback_fixtures/get_list.json

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,46 +4,46 @@
44
"orderindex": 1,
55
"content": "Updated List Content",
66
"status": {
7-
"status": "red",
8-
"color": "#e50000",
9-
"hide_label": true
7+
"status": "red",
8+
"color": "#e50000",
9+
"hide_label": true
1010
},
1111
"priority": {
12-
"priority": "high",
13-
"color": "#f50000"
12+
"priority": "high",
13+
"color": "#f50000"
1414
},
1515
"assignee": null,
1616
"due_date": "1567780450202",
1717
"due_date_time": true,
1818
"start_date": null,
1919
"start_date_time": null,
2020
"folder": {
21-
"id": "456",
22-
"name": "Folder Name",
23-
"hidden": false,
24-
"access": true
21+
"id": "456",
22+
"name": "Folder Name",
23+
"hidden": false,
24+
"access": true
2525
},
2626
"space": {
27-
"id": "789",
28-
"name": "Space Name",
29-
"access": true
27+
"id": "789",
28+
"name": "Space Name",
29+
"access": true
3030
},
3131
"inbound_address": "add.task.124.ac725f.31518a6a-05bb-4997-92a6-1dcfe2f527ca@tasks.clickup.com",
3232
"archived": false,
3333
"override_statuses": false,
3434
"statuses": [
35-
{
36-
"status": "to do",
37-
"orderindex": 0,
38-
"color": "#d3d3d3",
39-
"type": "open"
40-
},
41-
{
42-
"status": "complete",
43-
"orderindex": 1,
44-
"color": "#6bc950",
45-
"type": "closed"
46-
}
35+
{
36+
"status": "to do",
37+
"orderindex": 0,
38+
"color": "#d3d3d3",
39+
"type": "open"
40+
},
41+
{
42+
"status": "complete",
43+
"orderindex": 1,
44+
"color": "#6bc950",
45+
"type": "closed"
46+
}
4747
],
4848
"permission_level": "create"
49-
}
49+
}

0 commit comments

Comments
 (0)