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 15cec69 commit b0efba5
Show file tree
Hide file tree
Showing 38 changed files with 1,677 additions and 0 deletions.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added static/images/integrations/clickup/001.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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.
1 change: 1 addition & 0 deletions static/images/integrations/logos/clickup.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions zerver/lib/integrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,7 @@ def __init__(self, name: str, *args: Any, **kwargs: Any) -> None:
WebhookIntegration("buildbot", ["continuous-integration"], display_name="Buildbot"),
WebhookIntegration("canarytoken", ["monitoring"], display_name="Thinkst Canarytokens"),
WebhookIntegration("circleci", ["continuous-integration"], display_name="CircleCI"),
WebhookIntegration("clickup", ["project-management"], display_name="ClickUp"),
WebhookIntegration("clubhouse", ["project-management"]),
WebhookIntegration("codeship", ["continuous-integration", "deployment"]),
WebhookIntegration("crashlytics", ["monitoring"]),
Expand Down Expand Up @@ -735,6 +736,7 @@ def __init__(self, name: str, *args: Any, **kwargs: Any) -> None:
ScreenshotConfig("bitbucket_job_completed.json", image_name="001.png"),
ScreenshotConfig("github_job_completed.json", image_name="002.png"),
],
"clickup": [ScreenshotConfig("task_moved.json")],
"clubhouse": [ScreenshotConfig("story_create.json")],
"codeship": [ScreenshotConfig("error_build.json")],
"crashlytics": [ScreenshotConfig("issue_message.json")],
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"
118 changes: 118 additions & 0 deletions zerver/webhooks/clickup/api_endpoints.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import re
from typing import Any, Dict, Optional, Union
from urllib.parse import urljoin

import requests
from django.utils.translation import gettext as _
from typing_extensions import override

from zerver.lib.exceptions import ErrorCode, WebhookError
from zerver.lib.outgoing_http import OutgoingSession
from zerver.webhooks.clickup import EventItemType


class APIUnavailableCallBackError(WebhookError):
"""Intended as an exception for when an integration
couldn't reach external API server when calling back
from Zulip app.
Exception when callback request has timed out or received
connection error.
"""

code = ErrorCode.REQUEST_TIMEOUT
http_status_code = 200
data_fields = ["webhook_name"]

def __init__(self) -> None:
super().__init__()

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


class BadRequestCallBackError(WebhookError):
"""Intended as an exception for when an integration
makes a bad request to external API server.
Exception when callback request has an invalid format.
"""

code = ErrorCode.BAD_REQUEST
http_status_code = 200
data_fields = ["webhook_name", "error_detail"]

def __init__(self, error_detail: Optional[Union[str, int]]) -> None:
super().__init__()
self.error_detail = error_detail

@staticmethod
@override
def msg_format() -> str:
return _(
"{webhook_name} integration tries to make a bad outgoing request: {error_detail}; ignoring"
)


class ClickUpSession(OutgoingSession):
def __init__(self, **kwargs: Any) -> None:
super().__init__(role="clickup", timeout=5, **kwargs) # nocoverage


def verify_url_path(path: str) -> bool:
parts = path.split("/")
if len(parts) < 2 or parts[0] not in EventItemType.as_list() or parts[1] == "":
return False
pattern = r"^[a-zA-Z0-9_-]+$"
match = re.match(pattern, parts[1])
return match is not None and match.group() == parts[1]


def make_clickup_request(path: str, api_key: str) -> Dict[str, Any]:
if verify_url_path(path) is False:
raise BadRequestCallBackError("Invalid path")
headers: Dict[str, str] = {
"Content-Type": "application/json",
"Authorization": api_key,
}

try:
base_url = "https://api.clickup.com/api/v2/"
api_endpoint = urljoin(base_url, path)
response = ClickUpSession(headers=headers).get(
api_endpoint,
)
response.raise_for_status()
except (requests.ConnectionError, requests.Timeout):
raise APIUnavailableCallBackError
except requests.HTTPError as e:
raise BadRequestCallBackError(e.response.status_code)

return response.json()


def get_list(api_key: str, list_id: str) -> Dict[str, Any]:
data = make_clickup_request(f"list/{list_id}", api_key)
return data


def get_task(api_key: str, task_id: str) -> Dict[str, Any]:
data = make_clickup_request(f"task/{task_id}", api_key)
return data


def get_folder(api_key: str, folder_id: str) -> Dict[str, Any]:
data = make_clickup_request(f"folder/{folder_id}", api_key)
return data


def get_goal(api_key: str, goal_id: str) -> Dict[str, Any]:
data = make_clickup_request(f"goal/{goal_id}", api_key)
return data


def get_space(api_key: str, space_id: str) -> Dict[str, Any]:
data = make_clickup_request(f"space/{space_id}", api_key)
return data
14 changes: 14 additions & 0 deletions zerver/webhooks/clickup/callback_fixtures/get_folder.json
Original file line number Diff line number Diff line change
@@ -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": []
}
33 changes: 33 additions & 0 deletions zerver/webhooks/clickup/callback_fixtures/get_goal.json
Original file line number Diff line number Diff line change
@@ -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": "[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"
}
}
49 changes: 49 additions & 0 deletions zerver/webhooks/clickup/callback_fixtures/get_list.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
{
"id": "124",
"name": "List-an al Gaib",
"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"
}
52 changes: 52 additions & 0 deletions zerver/webhooks/clickup/callback_fixtures/get_space.json
Original file line number Diff line number Diff line change
@@ -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
}
}
}
63 changes: 63 additions & 0 deletions zerver/webhooks/clickup/callback_fixtures/get_task.json
Original file line number Diff line number Diff line change
@@ -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": "[email protected]",
"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"
}
Loading

0 comments on commit b0efba5

Please sign in to comment.