Skip to content

Commit 8cd558a

Browse files
integrations/clickup: Add ClickUp integration script.
ClickUp payloads contain only entity IDs, so the server needs a stored ClickUp token to resolve them to names. Use a personal API token instead of OAuth: the token must be present in the bot config at bot-creation time, whereas an OAuth flow can only issue one after the bot and webhook URL already exist. Fixes part of zulip/zulip#26529 Co-Authored-By: pieterck <pieterceka123@gmail.com>
1 parent b172347 commit 8cd558a

4 files changed

Lines changed: 578 additions & 0 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# A script that automates setting up a webhook with ClickUp
2+
3+
This script registers a ClickUp webhook that points at your Zulip
4+
incoming webhook bot, so that ClickUp events show up as messages in
5+
Zulip.
6+
7+
Usage:
8+
9+
1. Gather the required credentials before running the script:
10+
- **ClickUp Team ID** — the number immediately following the base
11+
ClickUp URL. For `https://app.clickup.com/25567147/home` the team
12+
ID is `25567147`.
13+
- **ClickUp personal API token** — generate one in ClickUp under
14+
*Settings > ClickUp API > API Token* (it looks like `pk_...`). The script
15+
uses it to register the webhook, and you enter the same token when
16+
creating the Zulip bot so the server can resolve ClickUp entity
17+
IDs to human-readable names.
18+
- **Zulip webhook URL** — the URL generated by your Zulip incoming
19+
webhook bot.
20+
21+
2. Run the script:
22+
23+
```
24+
$ python zulip_clickup.py \
25+
--clickup-team-id <clickup_team_id> \
26+
--clickup-api-key <clickup_api_key> \
27+
--zulip-webhook-url "<zulip_webhook_url>"
28+
```
29+
30+
The script asks which ClickUp events you'd like to receive, then
31+
registers the webhook.
32+
33+
3. (Optional) Pass `--replace-existing` to delete any webhook already
34+
pointing at the same Zulip URL before creating a new one. Use this
35+
when re-running the script so you don't end up with duplicate
36+
webhooks.
37+
38+
For more information, please see Zulip's documentation on how to set up
39+
a ClickUp integration [here](https://zulip.com/integrations/doc/clickup).

zulip/integrations/clickup/__init__.py

Whitespace-only changes.
Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
import io
2+
from typing import Any, List
3+
from unittest import TestCase
4+
from unittest.mock import MagicMock, patch
5+
from urllib.error import HTTPError, URLError
6+
7+
from typing_extensions import override
8+
9+
from integrations.clickup import zulip_clickup
10+
from integrations.clickup.zulip_clickup import ClickUpAPIHandler
11+
12+
SCRIPT_PATH = "integrations.clickup.zulip_clickup"
13+
14+
MOCK_WEBHOOK_URL = (
15+
"https://YourZulipApp.com/api/v1/external/clickup?api_key=TJ9DnIiNqt51bpfyPll5n2uT4iYxMBW9"
16+
)
17+
18+
MOCK_API_KEY = "pk_" + "1" * 32
19+
CLICKUP_TEAM_ID = "teamid123"
20+
21+
MOCK_CREATED_WEBHOOK_ID = "13-13-13-13-1313-13"
22+
MOCK_DELETE_WEBHOOK_ID = "12-12-12-12-12"
23+
24+
REQUIRED_ARGS = [
25+
"zulip_clickup.py",
26+
"--clickup-team-id",
27+
CLICKUP_TEAM_ID,
28+
"--clickup-api-key",
29+
MOCK_API_KEY,
30+
"--zulip-webhook-url",
31+
MOCK_WEBHOOK_URL,
32+
]
33+
34+
35+
def make_urlopen_response(status: int, body: bytes) -> MagicMock:
36+
"""Build a mock that behaves like the context manager urlopen() returns."""
37+
response = MagicMock()
38+
response.status = status
39+
response.read.return_value = body
40+
response.__enter__.return_value = response
41+
response.__exit__.return_value = False
42+
return response
43+
44+
45+
class ArgumentParsingTest(TestCase):
46+
@patch("sys.stdout", new_callable=io.StringIO)
47+
@patch(SCRIPT_PATH + ".run")
48+
@patch("sys.argv", REQUIRED_ARGS)
49+
def test_valid_arguments(self, mock_run: MagicMock, mock_stdout: io.StringIO) -> None:
50+
zulip_clickup.main()
51+
self.assertRegex(mock_stdout.getvalue(), r"Running Zulip Clickup Integration...")
52+
mock_run.assert_called_once_with(CLICKUP_TEAM_ID, MOCK_API_KEY, MOCK_WEBHOOK_URL, False)
53+
54+
@patch("sys.stdout", new_callable=io.StringIO)
55+
@patch(SCRIPT_PATH + ".run")
56+
@patch("sys.argv", [*REQUIRED_ARGS, "--replace-existing"])
57+
def test_replace_existing_flag(self, mock_run: MagicMock, mock_stdout: io.StringIO) -> None:
58+
zulip_clickup.main()
59+
self.assertRegex(mock_stdout.getvalue(), r"Running Zulip Clickup Integration...")
60+
mock_run.assert_called_once_with(CLICKUP_TEAM_ID, MOCK_API_KEY, MOCK_WEBHOOK_URL, True)
61+
62+
@patch("sys.stderr", new_callable=io.StringIO)
63+
@patch("sys.argv", ["zulip_clickup.py"])
64+
def test_missing_arguments(self, mock_stderr: io.StringIO) -> None:
65+
with self.assertRaises(SystemExit) as cm:
66+
zulip_clickup.main()
67+
self.assertEqual(cm.exception.code, 2)
68+
self.assertRegex(
69+
mock_stderr.getvalue(),
70+
r"the following arguments are required: "
71+
r"--clickup-team-id, --clickup-api-key, --zulip-webhook-url",
72+
)
73+
74+
75+
class EventSelectionTest(TestCase):
76+
@patch("sys.stdout", new_callable=io.StringIO)
77+
@patch("builtins.input", side_effect=["1"])
78+
def test_select_event_group(self, mock_input: MagicMock, mock_stdout: io.StringIO) -> None:
79+
events = zulip_clickup.query_for_notification_events()
80+
menu = mock_stdout.getvalue()
81+
for expected in (
82+
"1 = task",
83+
"2 = list",
84+
"3 = folder",
85+
"4 = space",
86+
"5 = goal",
87+
"6 = key result",
88+
):
89+
self.assertIn(expected, menu)
90+
self.assertEqual(events, ["taskCreated", "taskUpdated", "taskDeleted"])
91+
92+
@patch("sys.stdout", new_callable=io.StringIO)
93+
@patch("builtins.input", side_effect=["1,2,3"])
94+
def test_select_multiple_event_groups(
95+
self, mock_input: MagicMock, mock_stdout: io.StringIO
96+
) -> None:
97+
events = zulip_clickup.query_for_notification_events()
98+
self.assertEqual(
99+
events,
100+
[
101+
"taskCreated",
102+
"taskUpdated",
103+
"taskDeleted",
104+
"listCreated",
105+
"listUpdated",
106+
"listDeleted",
107+
"folderCreated",
108+
"folderUpdated",
109+
"folderDeleted",
110+
],
111+
)
112+
113+
@patch("sys.stdout", new_callable=io.StringIO)
114+
@patch("builtins.input", side_effect=["*"])
115+
def test_select_all_events(self, mock_input: MagicMock, mock_stdout: io.StringIO) -> None:
116+
events = zulip_clickup.query_for_notification_events()
117+
expected = [event for group in zulip_clickup.EVENT_CHOICES.values() for event in group]
118+
self.assertEqual(events, expected)
119+
120+
@patch("sys.stdout", new_callable=io.StringIO)
121+
@patch("builtins.input", side_effect=["9", "1"])
122+
def test_invalid_then_valid_input(
123+
self, mock_input: MagicMock, mock_stdout: io.StringIO
124+
) -> None:
125+
events = zulip_clickup.query_for_notification_events()
126+
self.assertEqual(events, ["taskCreated", "taskUpdated", "taskDeleted"])
127+
self.assertRegex(
128+
mock_stdout.getvalue(),
129+
r"Please enter a valid set of options and only select each option once",
130+
)
131+
132+
133+
class ClickUpAPIHandlerTest(TestCase):
134+
@override
135+
def setUp(self) -> None:
136+
self.handler = ClickUpAPIHandler(MOCK_API_KEY, CLICKUP_TEAM_ID)
137+
138+
def test_constructor_and_endpoints(self) -> None:
139+
self.assertEqual(self.handler.api_key, MOCK_API_KEY)
140+
self.assertEqual(self.handler.team_id, CLICKUP_TEAM_ID)
141+
self.assertEqual(set(self.handler.ENDPOINTS), {"team", "webhook"})
142+
143+
@patch(SCRIPT_PATH + ".urlopen", return_value=make_urlopen_response(200, b'{"id": "abc"}'))
144+
def test_valid_request(self, mock_urlopen: MagicMock) -> None:
145+
data = self.handler.make_clickup_request(
146+
self.handler.ENDPOINTS["team"],
147+
{"endpoint": "u", "events": ["taskCreated"]},
148+
"POST",
149+
)
150+
self.assertEqual(data, {"id": "abc"})
151+
152+
@patch(SCRIPT_PATH + ".urlopen", return_value=make_urlopen_response(200, b""))
153+
def test_response_with_empty_body(self, mock_urlopen: MagicMock) -> None:
154+
data = self.handler.make_clickup_request("webhook/1", {}, "DELETE")
155+
self.assertEqual(data, {})
156+
157+
def test_get_and_delete_send_no_body(self) -> None:
158+
captured: List[Any] = []
159+
160+
def fake_urlopen(req: Any) -> MagicMock:
161+
captured.append(req)
162+
return make_urlopen_response(200, b"{}")
163+
164+
with patch(SCRIPT_PATH + ".urlopen", side_effect=fake_urlopen):
165+
self.handler.make_clickup_request(self.handler.ENDPOINTS["team"], {}, "GET")
166+
self.handler.make_clickup_request(
167+
self.handler.ENDPOINTS["team"], {"endpoint": "u", "events": ["x"]}, "POST"
168+
)
169+
self.handler.make_clickup_request("webhook/1", {}, "DELETE")
170+
171+
get_req, post_req, delete_req = captured
172+
self.assertIsNone(get_req.data)
173+
self.assertIsNotNone(post_req.data)
174+
self.assertIsNone(delete_req.data)
175+
176+
def test_authorization_header_is_raw_token(self) -> None:
177+
captured: List[Any] = []
178+
179+
def fake_urlopen(req: Any) -> MagicMock:
180+
captured.append(req)
181+
return make_urlopen_response(200, b"{}")
182+
183+
with patch(SCRIPT_PATH + ".urlopen", side_effect=fake_urlopen):
184+
self.handler.make_clickup_request(self.handler.ENDPOINTS["team"], {}, "GET")
185+
self.assertEqual(captured[0].headers["Authorization"], MOCK_API_KEY)
186+
187+
def test_response_with_httperror(self) -> None:
188+
err = HTTPError(MOCK_WEBHOOK_URL, 403, "Forbidden", {}, None) # type: ignore[arg-type]
189+
with patch("sys.stdout", new_callable=io.StringIO) as mock_stdout:
190+
with patch(SCRIPT_PATH + ".urlopen", side_effect=err):
191+
data = self.handler.make_clickup_request(self.handler.ENDPOINTS["team"], {}, "GET")
192+
self.assertIsNone(data)
193+
self.assertRegex(mock_stdout.getvalue(), r"HTTPError occurred: 403")
194+
195+
def test_response_with_urlerror(self) -> None:
196+
with patch("sys.stdout", new_callable=io.StringIO) as mock_stdout:
197+
with patch(SCRIPT_PATH + ".urlopen", side_effect=URLError("network down")):
198+
data = self.handler.make_clickup_request(self.handler.ENDPOINTS["team"], {}, "GET")
199+
self.assertIsNone(data)
200+
self.assertRegex(mock_stdout.getvalue(), r"Network error occurred")
201+
202+
@patch(
203+
SCRIPT_PATH + ".urlopen",
204+
return_value=make_urlopen_response(200, b"<html>Service Unavailable</html>"),
205+
)
206+
def test_response_with_non_json_body(self, mock_urlopen: MagicMock) -> None:
207+
with patch("sys.stdout", new_callable=io.StringIO) as mock_stdout:
208+
data = self.handler.make_clickup_request(self.handler.ENDPOINTS["team"], {}, "GET")
209+
self.assertIsNone(data)
210+
self.assertRegex(mock_stdout.getvalue(), r"Could not parse ClickUp's response")
211+
212+
@patch.object(
213+
ClickUpAPIHandler, "make_clickup_request", return_value={"id": MOCK_CREATED_WEBHOOK_ID}
214+
)
215+
def test_create_webhook(self, mock_request: MagicMock) -> None:
216+
data = self.handler.create_webhook(MOCK_WEBHOOK_URL, ["taskCreated"])
217+
self.assertEqual(data["id"], MOCK_CREATED_WEBHOOK_ID)
218+
219+
@patch("sys.stdout", new_callable=io.StringIO)
220+
@patch.object(ClickUpAPIHandler, "make_clickup_request", return_value=None)
221+
def test_create_webhook_with_failure(
222+
self, mock_request: MagicMock, mock_stdout: io.StringIO
223+
) -> None:
224+
with self.assertRaises(SystemExit) as cm:
225+
self.handler.create_webhook(MOCK_WEBHOOK_URL, ["taskCreated"])
226+
self.assertEqual(cm.exception.code, 1)
227+
228+
@patch("sys.stdout", new_callable=io.StringIO)
229+
@patch.object(ClickUpAPIHandler, "make_clickup_request", return_value=None)
230+
def test_get_webhooks_with_failure(
231+
self, mock_request: MagicMock, mock_stdout: io.StringIO
232+
) -> None:
233+
with self.assertRaises(SystemExit) as cm:
234+
self.handler.get_webhooks()
235+
self.assertEqual(cm.exception.code, 1)
236+
237+
238+
class DeleteOldWebhooksTest(TestCase):
239+
@patch.object(ClickUpAPIHandler, "delete_webhook")
240+
@patch.object(ClickUpAPIHandler, "get_webhooks")
241+
def test_matches_exact_url_only(
242+
self, mock_get_webhooks: MagicMock, mock_delete_webhook: MagicMock
243+
) -> None:
244+
target = MOCK_WEBHOOK_URL
245+
same_host_other_bot = "https://YourZulipApp.com/api/v1/external/clickup?api_key=SOMEOTHERBOTKEY0000000000000000"
246+
mock_get_webhooks.return_value = {
247+
"webhooks": [
248+
{"id": "match", "endpoint": target},
249+
{"id": "other-bot", "endpoint": same_host_other_bot},
250+
]
251+
}
252+
deleted: List[str] = []
253+
mock_delete_webhook.side_effect = deleted.append
254+
255+
handler = ClickUpAPIHandler(MOCK_API_KEY, CLICKUP_TEAM_ID)
256+
zulip_clickup.delete_old_webhooks(target, handler)
257+
258+
self.assertEqual(deleted, ["match"])
259+
260+
261+
class RunTest(TestCase):
262+
@patch("sys.stdout", new_callable=io.StringIO)
263+
@patch(SCRIPT_PATH + ".query_for_notification_events", return_value=["taskCreated"])
264+
@patch(SCRIPT_PATH + ".delete_old_webhooks")
265+
@patch.object(ClickUpAPIHandler, "create_webhook", return_value={"id": MOCK_CREATED_WEBHOOK_ID})
266+
def test_run_without_replace_skips_delete(
267+
self,
268+
mock_create: MagicMock,
269+
mock_delete_old: MagicMock,
270+
mock_events: MagicMock,
271+
mock_stdout: io.StringIO,
272+
) -> None:
273+
with self.assertRaises(SystemExit) as cm:
274+
zulip_clickup.run(CLICKUP_TEAM_ID, MOCK_API_KEY, MOCK_WEBHOOK_URL, False)
275+
self.assertEqual(cm.exception.code, 0)
276+
mock_delete_old.assert_not_called()
277+
mock_create.assert_called_once()
278+
self.assertRegex(mock_stdout.getvalue(), r"SUCCESS: Completed integrating")
279+
280+
@patch("sys.stdout", new_callable=io.StringIO)
281+
@patch(SCRIPT_PATH + ".query_for_notification_events", return_value=["taskCreated"])
282+
@patch(SCRIPT_PATH + ".delete_old_webhooks")
283+
@patch.object(ClickUpAPIHandler, "create_webhook", return_value={"id": MOCK_CREATED_WEBHOOK_ID})
284+
def test_run_with_replace_calls_delete(
285+
self,
286+
mock_create: MagicMock,
287+
mock_delete_old: MagicMock,
288+
mock_events: MagicMock,
289+
mock_stdout: io.StringIO,
290+
) -> None:
291+
with self.assertRaises(SystemExit) as cm:
292+
zulip_clickup.run(CLICKUP_TEAM_ID, MOCK_API_KEY, MOCK_WEBHOOK_URL, True)
293+
self.assertEqual(cm.exception.code, 0)
294+
mock_delete_old.assert_called_once()
295+
self.assertEqual(mock_delete_old.call_args[0][0], MOCK_WEBHOOK_URL)
296+
mock_create.assert_called_once()

0 commit comments

Comments
 (0)