Skip to content

Commit 2fa6bd7

Browse files
committed
integrations: Add ClickUp integration script.
Add a python script to help integrate Zulip with Clickup. Urlopen is used instead of the usual requests library inorder to make the script standalone. Fixes zulip#26529
1 parent e9d8ef3 commit 2fa6bd7

File tree

4 files changed

+574
-0
lines changed

4 files changed

+574
-0
lines changed

zulip/integrations/clickup/README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# A script that automates setting up a webhook with ClickUp
2+
3+
Usage :
4+
5+
1. Make sure you have all of the relevant ClickUp credentials before
6+
executing the script:
7+
- The ClickUp Team ID
8+
- The ClickUp Client ID
9+
- The ClickUp Client Secret
10+
11+
2. Execute the script :
12+
13+
$ python zulip_clickup.py --clickup-team-id <clickup_team_id> \
14+
--clickup-client-id <clickup_client_id> \
15+
--clickup-client-secret <clickup_client_secret> \
16+
--zulip-webhook-url "GENERATED_WEBHOOK_URL"
17+
18+
For more information, please see Zulip's documentation on how to set up
19+
a ClickUp integration [here](https://zulip.com/integrations/doc/clickup).

zulip/integrations/clickup/__init__.py

Whitespace-only changes.
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import io
2+
import re
3+
from functools import wraps
4+
from typing import Any, Callable, Dict, List, Optional, Union
5+
from unittest import TestCase
6+
from unittest.mock import DEFAULT, patch
7+
8+
from integrations.clickup import zulip_clickup
9+
from integrations.clickup.zulip_clickup import ClickUpAPIHandler
10+
11+
MOCK_WEBHOOK_URL = (
12+
"https://YourZulipApp.com/api/v1/external/clickup?api_key=TJ9DnIiNqt51bpfyPll5n2uT4iYxMBW9"
13+
)
14+
15+
MOCK_AUTH_CODE = "332KKA3321NNAK3MADS"
16+
MOCK_AUTH_CODE_URL = f"https://YourZulipApp.com/?code={MOCK_AUTH_CODE}"
17+
MOCK_API_KEY = "X" * 32
18+
19+
SCRIPT_PATH = "integrations.clickup.zulip_clickup"
20+
21+
MOCK_CREATED_WEBHOOK_ID = "13-13-13-13-1313-13"
22+
MOCK_DELETE_WEBHOOK_ID = "12-12-12-12-12"
23+
MOCK_GET_WEBHOOK_IDS = {"endpoint": MOCK_WEBHOOK_URL, "id": MOCK_DELETE_WEBHOOK_ID}
24+
25+
CLICKUP_TEAM_ID = "teamid123"
26+
CLICKUP_CLIENT_ID = "clientid321"
27+
CLICKUP_CLIENT_SECRET = "clientsecret322" # noqa: S105
28+
29+
30+
def make_clickup_request_side_effect(
31+
path: str, query: Dict[str, Union[str, List[str]]], method: str
32+
) -> Optional[Dict[str, Any]]:
33+
clickup_api = ClickUpAPIHandler(CLICKUP_CLIENT_ID, CLICKUP_CLIENT_SECRET, CLICKUP_TEAM_ID)
34+
api_data_mapper: Dict[str, Dict[str, Dict[str, Any]]] = { # path -> method -> response
35+
clickup_api.ENDPOINTS["oauth"]: {
36+
"POST": {"access_token": MOCK_API_KEY},
37+
},
38+
clickup_api.ENDPOINTS["team"]: {
39+
"POST": {"id": MOCK_CREATED_WEBHOOK_ID},
40+
"GET": {"webhooks": [MOCK_GET_WEBHOOK_IDS]},
41+
},
42+
clickup_api.ENDPOINTS["webhook"].format(webhook_id=MOCK_DELETE_WEBHOOK_ID): {"DELETE": {}},
43+
}
44+
return api_data_mapper.get(path, {}).get(method, DEFAULT)
45+
46+
47+
def mock_script_args(selected_events: str = "1,2,3,4,5") -> Callable[[Any], Callable[..., Any]]:
48+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
49+
@wraps(func)
50+
def wrapper(*args: Any, **kwargs: Any) -> Any:
51+
mock_user_inputs = [MOCK_AUTH_CODE_URL, selected_events]
52+
with patch(
53+
"sys.argv",
54+
[
55+
"zulip_clickup.py",
56+
"--clickup-team-id",
57+
CLICKUP_TEAM_ID,
58+
"--clickup-client-id",
59+
CLICKUP_CLIENT_ID,
60+
"--clickup-client-secret",
61+
CLICKUP_CLIENT_SECRET,
62+
"--zulip-webhook-url",
63+
MOCK_WEBHOOK_URL,
64+
],
65+
), patch("sys.exit"), patch("builtins.input", side_effect=mock_user_inputs), patch(
66+
SCRIPT_PATH + ".ClickUpAPIHandler.make_clickup_request",
67+
side_effect=make_clickup_request_side_effect,
68+
):
69+
result = func(*args, **kwargs)
70+
71+
return result
72+
73+
return wrapper
74+
75+
return decorator
76+
77+
78+
class ZulipClickUpScriptTest(TestCase):
79+
@mock_script_args()
80+
def test_valid_arguments(self) -> None:
81+
with patch(SCRIPT_PATH + ".run") as mock_run, patch(
82+
"sys.stdout", new=io.StringIO()
83+
) as mock_stdout:
84+
zulip_clickup.main()
85+
self.assertRegex(mock_stdout.getvalue(), r"Running Zulip Clickup Integration...")
86+
mock_run.assert_called_once_with(
87+
CLICKUP_CLIENT_ID, CLICKUP_CLIENT_SECRET, CLICKUP_TEAM_ID, MOCK_WEBHOOK_URL
88+
)
89+
90+
def test_missing_arguments(self) -> None:
91+
with self.assertRaises(SystemExit) as cm:
92+
with patch("sys.stderr", new=io.StringIO()) as mock_stderr:
93+
zulip_clickup.main()
94+
self.assertEqual(cm.exception.code, 2)
95+
self.assertRegex(
96+
mock_stderr.getvalue(),
97+
r"""the following arguments are required: --clickup-team-id, --clickup-client-id, --clickup-client-secret, --zulip-webhook-url\n""",
98+
)
99+
100+
@mock_script_args()
101+
def test_redirect_to_auth_page(self) -> None:
102+
with patch("webbrowser.open") as mock_open, patch(
103+
"sys.stdout", new=io.StringIO()
104+
) as mock_stdout:
105+
zulip_clickup.main()
106+
redirect_uri = "https://YourZulipApp.com"
107+
mock_open.assert_called_once_with(
108+
f"https://app.clickup.com/api?client_id={CLICKUP_CLIENT_ID}&redirect_uri={redirect_uri}"
109+
)
110+
expected_output = (
111+
r"STEP 1[\s\S]*"
112+
r"ClickUp authorization page will open in your browser\.[\s\S]*"
113+
r"Please authorize your workspace\(s\)\.[\s\S]*"
114+
r"Click 'Connect Workspace' on the page to proceed\.\.\."
115+
)
116+
117+
self.assertRegex(
118+
mock_stdout.getvalue(),
119+
expected_output,
120+
)
121+
122+
@mock_script_args()
123+
def test_query_for_auth_code(self) -> None:
124+
with patch("webbrowser.open"), patch("sys.stdout", new=io.StringIO()) as mock_stdout:
125+
zulip_clickup.main()
126+
expected_output = (
127+
"STEP 2\n----\nAfter you've authorized your workspace,\n"
128+
"you should be redirected to your home URL.\n"
129+
"Please copy your home URL and paste it below.\n"
130+
"It should contain a code, and look similar to this:\n\n"
131+
"e.g. " + re.escape(MOCK_AUTH_CODE_URL)
132+
)
133+
self.assertRegex(
134+
mock_stdout.getvalue(),
135+
expected_output,
136+
)
137+
138+
@mock_script_args()
139+
def test_select_clickup_events(self) -> None:
140+
with patch("webbrowser.open"), patch("sys.stdout", new=io.StringIO()) as mock_stdout:
141+
zulip_clickup.main()
142+
expected_output = r"""
143+
STEP 3
144+
----
145+
Please select which ClickUp event notification\(s\) you'd
146+
like to receive in your Zulip app\.
147+
EVENT CODES:
148+
1 = task
149+
2 = list
150+
3 = folder
151+
4 = space
152+
5 = goals
153+
154+
Or, enter \* to subscribe to all events\.
155+
156+
Here's an example input if you intend to only receive notifications
157+
related to task, list and folder: 1,2,3
158+
"""
159+
self.assertRegex(
160+
mock_stdout.getvalue(),
161+
expected_output,
162+
)
163+
164+
@mock_script_args()
165+
def test_success_message(self) -> None:
166+
with patch("webbrowser.open"), patch("sys.stdout", new=io.StringIO()) as mock_stdout:
167+
zulip_clickup.main()
168+
expected_output = r"SUCCESS: Completed integrating your Zulip app with ClickUp!\s*webhook_id: \d+-\d+-\d+-\d+-\d+-\d+\s*You may delete this script or run it again to reconfigure\s*your integration\."
169+
self.assertRegex(mock_stdout.getvalue(), expected_output)
170+
171+
@mock_script_args(selected_events="*")
172+
def test_select_all_events(self) -> None:
173+
with patch("webbrowser.open"), patch("sys.stdout", new=io.StringIO()) as mock_stdout:
174+
zulip_clickup.main()
175+
expected_output = (
176+
r"Please enter a valid set of options and only select each option once"
177+
)
178+
self.assertNotRegex(
179+
mock_stdout.getvalue(),
180+
expected_output,
181+
)
182+
183+
@mock_script_args(selected_events="123123")
184+
def test_select_invalid_events(self) -> None:
185+
with patch("webbrowser.open"), patch("sys.stdout", new=io.StringIO()) as mock_stdout:
186+
with self.assertRaises(StopIteration):
187+
zulip_clickup.main()
188+
189+
expected_output = (
190+
r"Please enter a valid set of options and only select each option once"
191+
)
192+
self.assertRegex(
193+
mock_stdout.getvalue(),
194+
expected_output,
195+
)
196+
197+
@mock_script_args(selected_events="1,1,1,1")
198+
def test_invalid_input_multiple_events(self) -> None:
199+
with patch("webbrowser.open"), patch("sys.stdout", new=io.StringIO()) as mock_stdout:
200+
with self.assertRaises(StopIteration):
201+
zulip_clickup.main()
202+
203+
expected_output = (
204+
r"Please enter a valid set of options and only select each option once"
205+
)
206+
self.assertRegex(
207+
mock_stdout.getvalue(),
208+
expected_output,
209+
)

0 commit comments

Comments
 (0)