Skip to content

Commit

Permalink
integrations: Add ClickUp integration.
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
PieterCK committed Apr 9, 2024
1 parent 8db806c commit 15c479d
Show file tree
Hide file tree
Showing 12 changed files with 438 additions and 288 deletions.
Binary file added static/images/integrations/clickup/002.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion zerver/lib/integrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from django.utils.translation import gettext_lazy
from django_stubs_ext import StrPromise
from typing_extensions import TypeAlias

from zerver.lib.storage import static_path

"""This module declares all of the (documented) integrations available
Expand Down Expand Up @@ -735,7 +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_created.json")],
"clickup": [ScreenshotConfig("task_moved.json")],
"clubhouse": [ScreenshotConfig("story_create.json")],
"codeship": [ScreenshotConfig("error_build.json")],
"crashlytics": [ScreenshotConfig("issue_message.json")],
Expand Down
45 changes: 45 additions & 0 deletions zerver/webhooks/clickup/__init__.py
Original file line number Diff line number Diff line change
@@ -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"
74 changes: 62 additions & 12 deletions zerver/webhooks/clickup/api_endpoints.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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()

Expand Down
24 changes: 12 additions & 12 deletions zerver/webhooks/clickup/callback_fixtures/get_folder.json
Original file line number Diff line number Diff line change
@@ -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": []
}
60 changes: 30 additions & 30 deletions zerver/webhooks/clickup/callback_fixtures/get_goal.json
Original file line number Diff line number Diff line change
@@ -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": "[email protected]",
"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": "[email protected]",
"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"
}
}
}
50 changes: 25 additions & 25 deletions zerver/webhooks/clickup/callback_fixtures/get_list.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,46 +4,46 @@
"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",
"due_date_time": true,
"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"
}
}
Loading

0 comments on commit 15c479d

Please sign in to comment.