Add ClickUp Toolkit (#10662)

- **Description:** Adds a toolkit to interact with the
[ClickUp](https://clickup.com/) [Public API](https://clickup.com/api/)
- **Dependencies:** None
- **Tag maintainer:** @rodrigo-georgian, @rodrigo-clickup,
@aiswaryasankarwork
- **Twitter handle:** 
- Aiswarya (https://twitter.com/Aiswarya_Sankar,
https://www.linkedin.com/in/sankaraiswarya/)
   - Rodrigo (https://www.linkedin.com/in/rodrigo-ceballos-lentini/)


---------

Co-authored-by: Aiswarya Sankar <aiswaryasankar@Aiswaryas-MacBook-Pro.local>
Co-authored-by: aiswaryasankarwork <143119412+aiswaryasankarwork@users.noreply.github.com>
Co-authored-by: Bagatur <baskaryan@gmail.com>
pull/10303/head
rodrigo-clickup 1 year ago committed by GitHub
parent 68901e1e40
commit 5944c1851b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -0,0 +1,855 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# ClickUp Langchiain Toolkit"
]
},
{
"cell_type": "code",
"execution_count": 1,
"metadata": {},
"outputs": [],
"source": [
"%reload_ext autoreload\n",
"%autoreload 2\n",
"from datetime import datetime\n",
"\n",
"from langchain.agents.agent_toolkits.clickup.toolkit import ClickupToolkit\n",
"\n",
"from langchain.agents import AgentType, initialize_agent\n",
"from langchain.llms import OpenAI\n",
"from langchain.utilities.clickup import ClickupAPIWrapper\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Init"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Get Authenticated\n",
"1. Create a [ClickUp App](https://help.clickup.com/hc/en-us/articles/6303422883095-Create-your-own-app-with-the-ClickUp-API)\n",
"2. Follow [these steps](https://clickup.com/api/developer-portal/authentication/) to get your `client_id` and `client_secret`.\n",
" - *Suggestion: use `https://google.com` as the redirect_uri. This is what we assume in the defaults for this toolkit.*\n",
"3. Copy/paste them and run the next cell to get your `code`\n"
]
},
{
"cell_type": "code",
"execution_count": 48,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Click this link, select your workspace, click `Connect Workspace`\n",
"https://app.clickup.com/api?client_id=ABC...&redirect_uri=https://google.com\n"
]
}
],
"source": [
"# Copilot Sandbox\n",
"oauth_client_id = \"ABC...\"\n",
"oauth_client_secret = \"123...\"\n",
"redirect_uri = \"https://google.com\"\n",
"\n",
"print('Click this link, select your workspace, click `Connect Workspace`')\n",
"print(ClickupAPIWrapper.get_access_code_url(oauth_client_id, redirect_uri))"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"The url should change to something like this https://www.google.com/?code=THISISMYCODERIGHTHERE.\n",
"\n",
"Next, copy/paste the `CODE` (THISISMYCODERIGHTHERE) generated in the URL in the cell below.\n"
]
},
{
"cell_type": "code",
"execution_count": 4,
"metadata": {},
"outputs": [],
"source": [
"code = \"THISISMYCODERIGHTHERE\""
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Get Access Token\n",
"Then, use the code below to get your `access_token`.\n",
"\n",
"*Important*: Each code is a one time code that will expire after use. The `access_token` can be used for a period of time. Make sure to copy paste the `access_token` once you get it!"
]
},
{
"cell_type": "code",
"execution_count": 5,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Error: {'err': 'Code already used', 'ECODE': 'OAUTH_014'}\n",
"You already used this code once. Go back a step and generate a new code.\n",
"Our best guess for the url to get a new code is:\n",
"https://app.clickup.com/api?client_id=B5D61F8EVO04PR0JX0U73984LLS9GI6P&redirect_uri=https://google.com\n"
]
}
],
"source": [
"access_token = ClickupAPIWrapper.get_access_token(oauth_client_id, oauth_client_secret, code)\n",
"\n",
"if access_token is not None:\n",
" print('Copy/paste this code, into the next cell so you can reuse it!')\n",
" print(access_token)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Create Toolkit"
]
},
{
"cell_type": "code",
"execution_count": 3,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Found team_id: 9011010153.\n",
"Most request require the team id, so we store it for you in the toolkit, we assume the first team in your list is the one you want. \n",
"Note: If you know this is the wrong ID, you can pass it at initialization.\n"
]
}
],
"source": [
"# Set your access token here\n",
"access_token = '12345678_myaccesstokengoeshere123'\n",
"access_token = '81928627_c009bf122ccf36ec3ba3e0ef748b07042c5e4217260042004a5934540cb61527'\n",
"\n",
"# Init toolkit\n",
"clickup_api_wrapper = ClickupAPIWrapper(access_token=access_token)\n",
"toolkit = ClickupToolkit.from_clickup_api_wrapper(clickup_api_wrapper)\n",
"print(f'Found team_id: {clickup_api_wrapper.team_id}.\\nMost request require the team id, so we store it for you in the toolkit, we assume the first team in your list is the one you want. \\nNote: If you know this is the wrong ID, you can pass it at initialization.')"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Create Agent"
]
},
{
"cell_type": "code",
"execution_count": 5,
"metadata": {},
"outputs": [],
"source": [
"llm = OpenAI(temperature=0, openai_api_key=\"\")\n",
"\n",
"agent = initialize_agent(\n",
" toolkit.get_tools(), llm, agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION, verbose=True\n",
")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Run"
]
},
{
"cell_type": "code",
"execution_count": 6,
"metadata": {},
"outputs": [],
"source": [
"# helper function for demo\n",
"def print_and_run(command):\n",
" print('\\033[94m$ COMMAND\\033[0m')\n",
" print(command)\n",
" print('\\n\\033[94m$ AGENT\\033[0m')\n",
" response = agent.run(command)\n",
" print(''.join(['-']*80))\n",
" return response\n",
" "
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Navigation\n",
"You can get the teams, folder and spaces your user has access to"
]
},
{
"cell_type": "code",
"execution_count": 7,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"\u001b[94m$ COMMAND\u001b[0m\n",
"Get all the teams that the user is authorized to access\n",
"\n",
"\u001b[94m$ AGENT\u001b[0m\n",
"\n",
"\n",
"\u001b[1m> Entering new AgentExecutor chain...\u001b[0m\n",
"\u001b[32;1m\u001b[1;3m I need to use the Get Teams tool\n",
"Action: Get Teams\n",
"Action Input: No necessary request parameters\u001b[0m\n",
"Observation: \u001b[38;5;200m\u001b[1;3m{'teams': [{'id': '9011010153', 'name': 'Task Copilot Sandbox Workspace 1', 'members': [{'id': 61681706, 'username': 'Aiswarya ', 'email': 'asankar@clickup.com', 'initials': 'A'}, {'id': 81928627, 'username': 'Rodrigo Ceballos Lentini', 'email': 'rlentini@clickup.com', 'initials': 'RL'}]}]}\u001b[0m\n",
"Thought:\u001b[32;1m\u001b[1;3m I now know the teams the user is authorized to access\n",
"Final Answer: The user is authorized to access the team 'Task Copilot Sandbox Workspace 1'.\u001b[0m\n",
"\n",
"\u001b[1m> Finished chain.\u001b[0m\n",
"--------------------------------------------------------------------------------\n",
"\u001b[94m$ COMMAND\u001b[0m\n",
"Get all the spaces available to the team\n",
"\n",
"\u001b[94m$ AGENT\u001b[0m\n",
"\n",
"\n",
"\u001b[1m> Entering new AgentExecutor chain...\u001b[0m\n",
"\u001b[32;1m\u001b[1;3m I need to use the API to get the spaces\n",
"Action: Get Teams\n",
"Action Input: No necessary request parameters\u001b[0m\n",
"Observation: \u001b[38;5;200m\u001b[1;3m{'teams': [{'id': '9011010153', 'name': 'Task Copilot Sandbox Workspace 1', 'members': [{'id': 61681706, 'username': 'Aiswarya ', 'email': 'asankar@clickup.com', 'initials': 'A'}, {'id': 81928627, 'username': 'Rodrigo Ceballos Lentini', 'email': 'rlentini@clickup.com', 'initials': 'RL'}]}]}\u001b[0m\n",
"Thought:\u001b[32;1m\u001b[1;3m I now have the list of teams\n",
"Final Answer: The list of teams available to the team is [{'id': '9011010153', 'name': 'Task Copilot Sandbox Workspace 1', 'members': [{'id': 61681706, 'username': 'Aiswarya ', 'email': 'asankar@clickup.com', 'initials': 'A'}, {'id': 81928627, 'username': 'Rodrigo Ceballos Lentini', 'email': 'rlentini@clickup.com', 'initials': 'RL'}]}]\u001b[0m\n",
"\n",
"\u001b[1m> Finished chain.\u001b[0m\n",
"--------------------------------------------------------------------------------\n",
"\u001b[94m$ COMMAND\u001b[0m\n",
"Get all the folders for the team\n",
"\n",
"\u001b[94m$ AGENT\u001b[0m\n",
"\n",
"\n",
"\u001b[1m> Entering new AgentExecutor chain...\u001b[0m\n",
"\u001b[32;1m\u001b[1;3m I need to get all the folders for the team\n",
"Action: Get all folders in the workspace\n",
"Action Input: {\"folder_id\": \"90130119692\"}\u001b[0m\n",
"Observation: \u001b[33;1m\u001b[1;3m{'spaces': [{'id': '90110075934', 'name': 'Test Space', 'color': None, 'private': False, 'avatar': None, 'admin_can_manage': False, 'statuses': [{'id': 'p90110075934_lBKIEh3r', 'status': 'Open', 'type': 'open', 'orderindex': 0, 'color': '#d3d3d3'}, {'id': 'p90110075934_AvVAnVqy', 'status': 'in progress', 'type': 'custom', 'orderindex': 1, 'color': '#a875ff'}, {'id': 'p90110075934_SftYWzGt', 'status': 'Closed', 'type': 'closed', 'orderindex': 2, 'color': '#6bc950'}], 'multiple_assignees': True, 'features': {'due_dates': {'enabled': True, 'start_date': True, 'remap_due_dates': False, 'remap_closed_due_date': False}, 'sprints': {'enabled': False}, 'time_tracking': {'enabled': True, 'harvest': False, 'rollup': False}, 'points': {'enabled': False}, 'custom_items': {'enabled': False}, 'priorities': {'enabled': True, 'priorities': [{'color': '#f50000', 'id': '1', 'orderindex': '1', 'priority': 'urgent'}, {'color': '#ffcc00', 'id': '2', 'orderindex': '2', 'priority': 'high'}, {'color': '#6fddff', 'id': '3', 'orderindex': '3', 'priority': 'normal'}, {'color': '#d8d8d8', 'id': '4', 'orderindex': '4', 'priority': 'low'}]}, 'tags': {'enabled': True}, 'check_unresolved': {'enabled': True, 'subtasks': None, 'checklists': None, 'comments': None}, 'zoom': {'enabled': False}, 'milestones': {'enabled': False}, 'custom_fields': {'enabled': True}, 'dependency_warning': {'enabled': True}, 'status_pies': {'enabled': False}, 'multiple_assignees': {'enabled': True}}, 'archived': False}]}\u001b[0m\n",
"Thought:\u001b[32;1m\u001b[1;3m I now know the folders in the team\n",
"Final Answer: The folders in the team are listed in the observation.\u001b[0m\n",
"\n",
"\u001b[1m> Finished chain.\u001b[0m\n",
"--------------------------------------------------------------------------------\n"
]
},
{
"data": {
"text/plain": [
"'The folders in the team are listed in the observation.'"
]
},
"execution_count": 7,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"print_and_run(\"Get all the teams that the user is authorized to access\")\n",
"print_and_run(\"Get all the spaces available to the team\")\n",
"print_and_run(\"Get all the folders for the team\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Task Operations\n",
"You can get, ask question about tasks and update them"
]
},
{
"cell_type": "code",
"execution_count": 32,
"metadata": {},
"outputs": [],
"source": [
"task_id = '8685mb5fn'"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"#### Basic attirbute getting and updating"
]
},
{
"cell_type": "code",
"execution_count": 33,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"\u001b[94m$ COMMAND\u001b[0m\n",
"Get task with id 8685mb5fn\n",
"\n",
"\u001b[94m$ AGENT\u001b[0m\n",
"\n",
"\n",
"\u001b[1m> Entering new AgentExecutor chain...\u001b[0m\n",
"\u001b[32;1m\u001b[1;3m I need to use the Get task tool\n",
"Action: Get task\n",
"Action Input: {\"task_id\": \"8685mb5fn\"}\u001b[0m\n",
"Observation: \u001b[36;1m\u001b[1;3m{'id': '8685mb5fn', 'name': 'dummy task 1', 'text_content': 'An old, boring task description', 'description': 'An old, boring task description', 'status': 'to do', 'creator_id': 81928627, 'creator_username': 'Rodrigo Ceballos Lentini', 'creator_email': 'rlentini@clickup.com', 'assignees': [], 'watcher_username': 'Rodrigo Ceballos Lentini', 'watcher_email': 'rlentini@clickup.com', 'priority': 'high', 'due_date': '1694764800000', 'start_date': None, 'points': None, 'team_id': '9011010153', 'project_id': '90110331875'}\u001b[0m\n",
"Thought:\u001b[32;1m\u001b[1;3m I now know the task details\n",
"Final Answer: The task with id 8685mb5fn has the following details: {'id': '8685mb5fn', 'name': 'dummy task 1', 'text_content': 'An old, boring task description', 'description': 'An old, boring task description', 'status': 'to do', 'creator_id': 81928627, 'creator_username': 'Rodrigo Ceballos Lentini', 'creator_email': 'rlentini@clickup.com', 'assignees': [], 'watcher_username': 'Rodrigo Ceballos Lentini', 'watcher_email': 'rlentini@clickup.com', 'priority': 'high', 'due_date': '1694764800000', 'start_date': None, 'points': None, 'team_id': '9011010153', 'project_id': '90110331875'}\u001b[0m\n",
"\n",
"\u001b[1m> Finished chain.\u001b[0m\n",
"--------------------------------------------------------------------------------\n",
"\u001b[94m$ COMMAND\u001b[0m\n",
"What is the description of task with id 8685mb5fn\n",
"\n",
"\u001b[94m$ AGENT\u001b[0m\n",
"\n",
"\n",
"\u001b[1m> Entering new AgentExecutor chain...\u001b[0m\n",
"\u001b[32;1m\u001b[1;3m I need to get the description of the task\n",
"Action: Get task attribute\n",
"Action Input: {\"task_id\": \"8685mb5fn\", \"attribute_name\": \"description\"}\u001b[0m\n",
"Observation: \u001b[33;1m\u001b[1;3mAn old, boring task description\u001b[0m\n",
"Thought:\u001b[32;1m\u001b[1;3m I now know the description of the task\n",
"Final Answer: An old, boring task description\u001b[0m\n",
"\n",
"\u001b[1m> Finished chain.\u001b[0m\n",
"--------------------------------------------------------------------------------\n",
"\u001b[94m$ COMMAND\u001b[0m\n",
"For task with id 8685mb5fn, change the description to 'A cool task descriptiont changed by AI!'\n",
"\n",
"\u001b[94m$ AGENT\u001b[0m\n",
"\n",
"\n",
"\u001b[1m> Entering new AgentExecutor chain...\u001b[0m\n",
"\u001b[32;1m\u001b[1;3m I need to update the description of a task\n",
"Action: Update task\n",
"Action Input: {\"task_id\": \"8685mb5fn\", \"attribute_name\": \"description\", \"value\": \"A cool task description changed by AI!\"}\u001b[0m\n",
"Observation: \u001b[36;1m\u001b[1;3m<Response [200]>\u001b[0m\n",
"Thought:\u001b[32;1m\u001b[1;3m I have successfully updated the task description\n",
"Final Answer: The description of task 8685mb5fn has been successfully changed to 'A cool task description changed by AI!'\u001b[0m\n",
"\n",
"\u001b[1m> Finished chain.\u001b[0m\n",
"--------------------------------------------------------------------------------\n",
"\u001b[94m$ COMMAND\u001b[0m\n",
"What is the description of task with id 8685mb5fn\n",
"\n",
"\u001b[94m$ AGENT\u001b[0m\n",
"\n",
"\n",
"\u001b[1m> Entering new AgentExecutor chain...\u001b[0m\n",
"\u001b[32;1m\u001b[1;3m I need to get the description of the task\n",
"Action: Get task attribute\n",
"Action Input: {\"task_id\": \"8685mb5fn\", \"attribute_name\": \"description\"}\u001b[0m\n",
"Observation: \u001b[33;1m\u001b[1;3mA cool task description changed by AI!\u001b[0m\n",
"Thought:\u001b[32;1m\u001b[1;3m I now know the description of the task\n",
"Final Answer: A cool task description changed by AI!\u001b[0m\n",
"\n",
"\u001b[1m> Finished chain.\u001b[0m\n",
"--------------------------------------------------------------------------------\n",
"\u001b[94m$ COMMAND\u001b[0m\n",
"For task with id 8685mb5fn, change the description to 'An old, boring task description'\n",
"\n",
"\u001b[94m$ AGENT\u001b[0m\n",
"\n",
"\n",
"\u001b[1m> Entering new AgentExecutor chain...\u001b[0m\n",
"\u001b[32;1m\u001b[1;3m I need to update the description of a task\n",
"Action: Update task\n",
"Action Input: {\"task_id\": \"8685mb5fn\", \"attribute_name\": \"description\", \"value\": \"An old, boring task description\"}\u001b[0m\n",
"Observation: \u001b[36;1m\u001b[1;3m<Response [200]>\u001b[0m\n",
"Thought:\u001b[32;1m\u001b[1;3m I now know the task description has been updated\n",
"Final Answer: The description of task 8685mb5fn has been updated to 'An old, boring task description'.\u001b[0m\n",
"\n",
"\u001b[1m> Finished chain.\u001b[0m\n",
"--------------------------------------------------------------------------------\n"
]
},
{
"data": {
"text/plain": [
"\"The description of task 8685mb5fn has been updated to 'An old, boring task description'.\""
]
},
"execution_count": 33,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"# We can get a task to inspect it's contents\n",
"print_and_run(f\"Get task with id {task_id}\")\n",
"\n",
"# We can get a specific attribute from a task\n",
"previous_description = print_and_run(f\"What is the description of task with id {task_id}\")\n",
"\n",
"# We can even update it!\n",
"print_and_run(f\"For task with id {task_id}, change the description to 'A cool task descriptiont changed by AI!'\")\n",
"print_and_run(f\"What is the description of task with id {task_id}\")\n",
"\n",
"# Undo what we did\n",
"print_and_run(f\"For task with id {task_id}, change the description to '{previous_description}'\")"
]
},
{
"cell_type": "code",
"execution_count": 35,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"\u001b[94m$ COMMAND\u001b[0m\n",
"Change the descrition task 8685mj6cd to 'Look ma no hands'\n",
"\n",
"\u001b[94m$ AGENT\u001b[0m\n",
"\n",
"\n",
"\u001b[1m> Entering new AgentExecutor chain...\u001b[0m\n",
"\u001b[32;1m\u001b[1;3m I need to update the description of a task\n",
"Action: Update task\n",
"Action Input: {\"task_id\": \"8685mj6cd\", \"attribute_name\": \"description\", \"value\": \"Look ma no hands\"}\u001b[0m\n",
"Observation: \u001b[36;1m\u001b[1;3m<Response [200]>\u001b[0m\n",
"Thought:\u001b[32;1m\u001b[1;3m The task description has been successfully updated\n",
"Final Answer: The description of task 8685mj6cd has been changed to 'Look ma no hands'.\u001b[0m\n",
"\n",
"\u001b[1m> Finished chain.\u001b[0m\n",
"--------------------------------------------------------------------------------\n"
]
},
{
"data": {
"text/plain": [
"\"The description of task 8685mj6cd has been changed to 'Look ma no hands'.\""
]
},
"execution_count": 35,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"print_and_run(\"Change the descrition task 8685mj6cd to 'Look ma no hands'\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"#### Advanced Attributes (Assignees)\n",
"You can query and update almost every thing about a task!"
]
},
{
"cell_type": "code",
"execution_count": 36,
"metadata": {},
"outputs": [],
"source": [
"user_id = 81928627"
]
},
{
"cell_type": "code",
"execution_count": 37,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"\u001b[94m$ COMMAND\u001b[0m\n",
"What are the assignees of task id 8685mb5fn?\n",
"\n",
"\u001b[94m$ AGENT\u001b[0m\n",
"\n",
"\n",
"\u001b[1m> Entering new AgentExecutor chain...\u001b[0m\n",
"\u001b[32;1m\u001b[1;3m I need to get the assignees of a task\n",
"Action: Get task attribute\n",
"Action Input: {\"task_id\": \"8685mb5fn\", \"attribute_name\": \"assignee\"}\u001b[0m\n",
"Observation: \u001b[33;1m\u001b[1;3mError: attribute_name = assignee was not found in task keys dict_keys(['id', 'name', 'text_content', 'description', 'status', 'creator_id', 'creator_username', 'creator_email', 'assignees', 'watcher_username', 'watcher_email', 'priority', 'due_date', 'start_date', 'points', 'team_id', 'project_id']). Please call again with one of the key names.\u001b[0m\n",
"Thought:\u001b[32;1m\u001b[1;3m I need to get the assignees of a task\n",
"Action: Get task attribute\n",
"Action Input: {\"task_id\": \"8685mb5fn\", \"attribute_name\": \"assignees\"}\u001b[0m\n",
"Observation: \u001b[33;1m\u001b[1;3m[]\u001b[0m\n",
"Thought:\u001b[32;1m\u001b[1;3m I now know the final answer\n",
"Final Answer: There are no assignees for task id 8685mb5fn.\u001b[0m\n",
"\n",
"\u001b[1m> Finished chain.\u001b[0m\n",
"--------------------------------------------------------------------------------\n",
"\u001b[94m$ COMMAND\u001b[0m\n",
"Remove user 81928627 from the assignees of task id 8685mb5fn\n",
"\n",
"\u001b[94m$ AGENT\u001b[0m\n",
"\n",
"\n",
"\u001b[1m> Entering new AgentExecutor chain...\u001b[0m\n",
"\u001b[32;1m\u001b[1;3m I need to update the assignees of a task\n",
"Action: Update task assignees\n",
"Action Input: {\"task_id\": \"8685mb5fn\", \"operation\": \"rem\", \"users\": [81928627]}\u001b[0m\n",
"Observation: \u001b[33;1m\u001b[1;3m<Response [200]>\u001b[0m\n",
"Thought:\u001b[32;1m\u001b[1;3m The user has been removed from the assignees of the task\n",
"Final Answer: User 81928627 has been removed from the assignees of task id 8685mb5fn.\u001b[0m\n",
"\n",
"\u001b[1m> Finished chain.\u001b[0m\n",
"--------------------------------------------------------------------------------\n",
"\u001b[94m$ COMMAND\u001b[0m\n",
"What are the assignees of task id 8685mb5fn?\n",
"\n",
"\u001b[94m$ AGENT\u001b[0m\n",
"\n",
"\n",
"\u001b[1m> Entering new AgentExecutor chain...\u001b[0m\n",
"\u001b[32;1m\u001b[1;3m I need to get the assignees of a task\n",
"Action: Get task attribute\n",
"Action Input: {\"task_id\": \"8685mb5fn\", \"attribute_name\": \"assignee\"}\u001b[0m\n",
"Observation: \u001b[33;1m\u001b[1;3mError: attribute_name = assignee was not found in task keys dict_keys(['id', 'name', 'text_content', 'description', 'status', 'creator_id', 'creator_username', 'creator_email', 'assignees', 'watcher_username', 'watcher_email', 'priority', 'due_date', 'start_date', 'points', 'team_id', 'project_id']). Please call again with one of the key names.\u001b[0m\n",
"Thought:\u001b[32;1m\u001b[1;3m I need to get the assignees of a task\n",
"Action: Get task attribute\n",
"Action Input: {\"task_id\": \"8685mb5fn\", \"attribute_name\": \"assignees\"}\u001b[0m\n",
"Observation: \u001b[33;1m\u001b[1;3m[]\u001b[0m\n",
"Thought:\u001b[32;1m\u001b[1;3m I now know the final answer\n",
"Final Answer: There are no assignees for task id 8685mb5fn.\u001b[0m\n",
"\n",
"\u001b[1m> Finished chain.\u001b[0m\n",
"--------------------------------------------------------------------------------\n",
"\u001b[94m$ COMMAND\u001b[0m\n",
"Add user 81928627 from the assignees of task id 8685mb5fn\n",
"\n",
"\u001b[94m$ AGENT\u001b[0m\n",
"\n",
"\n",
"\u001b[1m> Entering new AgentExecutor chain...\u001b[0m\n",
"\u001b[32;1m\u001b[1;3m I need to update the assignees of a task\n",
"Action: Update task assignees\n",
"Action Input: {\"task_id\": \"8685mb5fn\", \"operation\": \"rem\", \"users\": [81928627]}\u001b[0m\n",
"Observation: \u001b[33;1m\u001b[1;3m<Response [200]>\u001b[0m\n",
"Thought:\u001b[32;1m\u001b[1;3m The user has been removed from the assignees of the task\n",
"Final Answer: User 81928627 has been removed from the assignees of task id 8685mb5fn.\u001b[0m\n",
"\n",
"\u001b[1m> Finished chain.\u001b[0m\n",
"--------------------------------------------------------------------------------\n"
]
},
{
"data": {
"text/plain": [
"'User 81928627 has been removed from the assignees of task id 8685mb5fn.'"
]
},
"execution_count": 37,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"print_and_run(f\"What are the assignees of task id {task_id}?\")\n",
"print_and_run(f\"Remove user {user_id} from the assignees of task id {task_id}\")\n",
"print_and_run(f\"What are the assignees of task id {task_id}?\")\n",
"print_and_run(f\"Add user {user_id} from the assignees of task id {task_id}\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Creation\n",
"You can create tasks, lists and folders"
]
},
{
"cell_type": "code",
"execution_count": 27,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"\u001b[94m$ COMMAND\u001b[0m\n",
"Create a task called 'Test Task - 18/09/2023-10:31:22' with description 'This is a Test'\n",
"\n",
"\u001b[94m$ AGENT\u001b[0m\n",
"\n",
"\n",
"\u001b[1m> Entering new AgentExecutor chain...\u001b[0m\n",
"\u001b[32;1m\u001b[1;3m I need to use the Create Task tool\n",
"Action: Create Task\n",
"Action Input: {\"name\": \"Test Task - 18/09/2023-10:31:22\", \"description\": \"This is a Test\"}\u001b[0m\n",
"Observation: \u001b[36;1m\u001b[1;3m{'id': '8685mw4wq', 'custom_id': None, 'name': 'Test Task - 18/09/2023-10:31:22', 'text_content': 'This is a Test', 'description': 'This is a Test', 'status': {'id': 'p90110061901_VlN8IJtk', 'status': 'to do', 'color': '#87909e', 'orderindex': 0, 'type': 'open'}, 'orderindex': '23.00000000000000000000000000000000', 'date_created': '1695047486396', 'date_updated': '1695047486396', 'date_closed': None, 'date_done': None, 'archived': False, 'creator': {'id': 81928627, 'username': 'Rodrigo Ceballos Lentini', 'color': '#c51162', 'email': 'rlentini@clickup.com', 'profilePicture': None}, 'assignees': [], 'watchers': [{'id': 81928627, 'username': 'Rodrigo Ceballos Lentini', 'color': '#c51162', 'initials': 'RL', 'email': 'rlentini@clickup.com', 'profilePicture': None}], 'checklists': [], 'tags': [], 'parent': None, 'priority': None, 'due_date': None, 'start_date': None, 'points': None, 'time_estimate': None, 'time_spent': 0, 'custom_fields': [], 'dependencies': [], 'linked_tasks': [], 'team_id': '9011010153', 'url': 'https://app.clickup.com/t/8685mw4wq', 'sharing': {'public': False, 'public_share_expires_on': None, 'public_fields': ['assignees', 'priority', 'due_date', 'content', 'comments', 'attachments', 'customFields', 'subtasks', 'tags', 'checklists', 'coverimage'], 'token': None, 'seo_optimized': False}, 'permission_level': 'create', 'list': {'id': '901100754275', 'name': 'Test List', 'access': True}, 'project': {'id': '90110336890', 'name': 'Test Folder', 'hidden': False, 'access': True}, 'folder': {'id': '90110336890', 'name': 'Test Folder', 'hidden': False, 'access': True}, 'space': {'id': '90110061901'}}\u001b[0m\n",
"Thought:\u001b[32;1m\u001b[1;3m I now know the final answer\n",
"Final Answer: A task called 'Test Task - 18/09/2023-10:31:22' with description 'This is a Test' was successfully created.\u001b[0m\n",
"\n",
"\u001b[1m> Finished chain.\u001b[0m\n",
"--------------------------------------------------------------------------------\n"
]
},
{
"data": {
"text/plain": [
"\"A task called 'Test Task - 18/09/2023-10:31:22' with description 'This is a Test' was successfully created.\""
]
},
"execution_count": 27,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"time_str = datetime.now().strftime(\"%d/%m/%Y-%H:%M:%S\")\n",
"print_and_run(f\"Create a task called 'Test Task - {time_str}' with description 'This is a Test'\")"
]
},
{
"cell_type": "code",
"execution_count": 29,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"\u001b[94m$ COMMAND\u001b[0m\n",
"Create a list called Test List - 18/09/2023-10:32:12\n",
"\n",
"\u001b[94m$ AGENT\u001b[0m\n",
"\n",
"\n",
"\u001b[1m> Entering new AgentExecutor chain...\u001b[0m\n",
"\u001b[32;1m\u001b[1;3m I need to create a list\n",
"Action: Create List\n",
"Action Input: {\"name\": \"Test List - 18/09/2023-10:32:12\"}\u001b[0m\n",
"Observation: \u001b[33;1m\u001b[1;3m{'id': '901100774700', 'name': 'Test List - 18/09/2023-10:32:12', 'deleted': False, 'orderindex': 13, 'content': '', 'priority': None, 'assignee': None, 'due_date': None, 'start_date': None, 'folder': {'id': '90110336890', 'name': 'Test Folder', 'hidden': False, 'access': True}, 'space': {'id': '90110061901', 'name': 'Space', 'access': True}, 'inbound_address': 'a.t.901100774700.u-81928627.20b87d50-eece-4721-b487-9ca500338587@tasks.clickup.com', 'archived': False, 'override_statuses': False, 'statuses': [{'id': 'p90110061901_VlN8IJtk', 'status': 'to do', 'orderindex': 0, 'color': '#87909e', 'type': 'open'}, {'id': 'p90110061901_14GpYKnM', 'status': 'complete', 'orderindex': 1, 'color': '#6bc950', 'type': 'closed'}], 'permission_level': 'create'}\u001b[0m\n",
"Thought:\u001b[32;1m\u001b[1;3m I now know the final answer\n",
"Final Answer: The list \"Test List - 18/09/2023-10:32:12\" has been created with id 901100774700.\u001b[0m\n",
"\n",
"\u001b[1m> Finished chain.\u001b[0m\n",
"--------------------------------------------------------------------------------\n"
]
},
{
"data": {
"text/plain": [
"'The list \"Test List - 18/09/2023-10:32:12\" has been created with id 901100774700.'"
]
},
"execution_count": 29,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"time_str = datetime.now().strftime(\"%d/%m/%Y-%H:%M:%S\")\n",
"print_and_run(f\"Create a list called Test List - {time_str}\")\n"
]
},
{
"cell_type": "code",
"execution_count": 30,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"\u001b[94m$ COMMAND\u001b[0m\n",
"Create a folder called 'Test Folder - 18/09/2023-10:32:51'\n",
"\n",
"\u001b[94m$ AGENT\u001b[0m\n",
"\n",
"\n",
"\u001b[1m> Entering new AgentExecutor chain...\u001b[0m\n",
"\u001b[32;1m\u001b[1;3m I need to use the Create Folder tool\n",
"Action: Create Folder\n",
"Action Input: {\"name\": \"Test Folder - 18/09/2023-10:32:51\"}\u001b[0m\n",
"Observation: \u001b[38;5;200m\u001b[1;3m{'id': '90110348711', 'name': 'Test Folder - 18/09/2023-10:32:51', 'orderindex': 12, 'override_statuses': False, 'hidden': False, 'space': {'id': '90110061901', 'name': 'Space', 'access': True}, 'task_count': '0', 'archived': False, 'statuses': [], 'lists': [], 'permission_level': 'create'}\u001b[0m\n",
"Thought:\u001b[32;1m\u001b[1;3m I have successfully created the folder\n",
"Final Answer: The folder 'Test Folder - 18/09/2023-10:32:51' has been successfully created.\u001b[0m\n",
"\n",
"\u001b[1m> Finished chain.\u001b[0m\n",
"--------------------------------------------------------------------------------\n"
]
},
{
"data": {
"text/plain": [
"\"The folder 'Test Folder - 18/09/2023-10:32:51' has been successfully created.\""
]
},
"execution_count": 30,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"time_str = datetime.now().strftime(\"%d/%m/%Y-%H:%M:%S\")\n",
"print_and_run(f\"Create a folder called 'Test Folder - {time_str}'\")\n"
]
},
{
"cell_type": "code",
"execution_count": 32,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"\u001b[94m$ COMMAND\u001b[0m\n",
"Create a list called 'Test List - 18/09/2023-10:34:01' with content My test list with high priority and status red\n",
"\n",
"\u001b[94m$ AGENT\u001b[0m\n",
"\n",
"\n",
"\u001b[1m> Entering new AgentExecutor chain...\u001b[0m\n",
"\u001b[32;1m\u001b[1;3m I need to create a list with the given name, content, priority and status\n",
"Action: Create List\n",
"Action Input: {\"name\": \"Test List - 18/09/2023-10:34:01\", \"content\": \"My test list\", \"priority\": 2, \"status\": \"red\"}\u001b[0m\n",
"Observation: \u001b[33;1m\u001b[1;3m{'id': '901100774746', 'name': 'Test List - 18/09/2023-10:34:01', 'deleted': False, 'orderindex': 15, 'content': '', 'status': {'status': 'red', 'color': '#e50000', 'hide_label': True}, 'priority': {'priority': 'high', 'color': '#ffcc00'}, 'assignee': None, 'due_date': None, 'start_date': None, 'folder': {'id': '90110336890', 'name': 'Test Folder', 'hidden': False, 'access': True}, 'space': {'id': '90110061901', 'name': 'Space', 'access': True}, 'inbound_address': 'a.t.901100774746.u-81928627.2ab87133-728e-4166-b2ae-423cc320df37@tasks.clickup.com', 'archived': False, 'override_statuses': False, 'statuses': [{'id': 'p90110061901_VlN8IJtk', 'status': 'to do', 'orderindex': 0, 'color': '#87909e', 'type': 'open'}, {'id': 'p90110061901_14GpYKnM', 'status': 'complete', 'orderindex': 1, 'color': '#6bc950', 'type': 'closed'}], 'permission_level': 'create'}\u001b[0m\n",
"Thought:\u001b[32;1m\u001b[1;3m I have successfully created the list\n",
"Final Answer: The list 'Test List - 18/09/2023-10:34:01' with content 'My test list' with high priority and status red has been successfully created.\u001b[0m\n",
"\n",
"\u001b[1m> Finished chain.\u001b[0m\n",
"--------------------------------------------------------------------------------\n"
]
},
{
"data": {
"text/plain": [
"\"The list 'Test List - 18/09/2023-10:34:01' with content 'My test list' with high priority and status red has been successfully created.\""
]
},
"execution_count": 32,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"time_str = datetime.now().strftime(\"%d/%m/%Y-%H:%M:%S\")\n",
"print_and_run(f\"Create a list called 'Test List - {time_str}' with content My test list with high priority and status red\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Multi-Step Tasks"
]
},
{
"cell_type": "code",
"execution_count": 34,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"\u001b[94m$ COMMAND\u001b[0m\n",
"Figure out what user ID Rodrigo is, create a task called 'Rod's task', assign it to Rodrigo\n",
"\n",
"\u001b[94m$ AGENT\u001b[0m\n",
"\n",
"\n",
"\u001b[1m> Entering new AgentExecutor chain...\u001b[0m\n",
"\u001b[32;1m\u001b[1;3m I need to get the user ID of Rodrigo, create a task, and assign it to Rodrigo\n",
"Action: Get Teams\n",
"Action Input: No input necessary\u001b[0m\n",
"Observation: \u001b[38;5;200m\u001b[1;3m{'teams': [{'id': '9011010153', 'name': 'Task Copilot Sandbox Workspace 1', 'members': [{'id': 81928627, 'username': 'Rodrigo Ceballos Lentini', 'email': 'rlentini@clickup.com', 'initials': 'RL'}]}]}\u001b[0m\n",
"Thought:\u001b[32;1m\u001b[1;3m I now have the user ID of Rodrigo\n",
"Action: Create Task\n",
"Action Input: {\"name\": \"Rod's task\", \"assignees\": [81928627]}\u001b[0m"
]
},
{
"name": "stderr",
"output_type": "stream",
"text": [
"/Users/rodrigolentini/repos/langchain-clickup/libs/langchain/langchain/utilities/clickup.py:145: UserWarning: Error encountered while trying to parse <class 'langchain.utilities.clickup.Task'>: 'NoneType' object is not subscriptable\n",
" Falling back to returning input data.\n",
" warnings.warn(f'Error encountered while trying to parse {dataclass}: {e}\\n Falling back to returning input data.')\n"
]
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"\n",
"Observation: \u001b[36;1m\u001b[1;3m{'id': '8685mw6dz', 'custom_id': None, 'name': \"Rod's task\", 'text_content': '', 'description': '', 'status': {'id': 'p90110061901_VlN8IJtk', 'status': 'to do', 'color': '#87909e', 'orderindex': 0, 'type': 'open'}, 'orderindex': '24.00000000000000000000000000000000', 'date_created': '1695047740939', 'date_updated': '1695047740939', 'date_closed': None, 'date_done': None, 'archived': False, 'creator': {'id': 81928627, 'username': 'Rodrigo Ceballos Lentini', 'color': '#c51162', 'email': 'rlentini@clickup.com', 'profilePicture': None}, 'assignees': [{'id': 81928627, 'username': 'Rodrigo Ceballos Lentini', 'color': '#c51162', 'initials': 'RL', 'email': 'rlentini@clickup.com', 'profilePicture': None}], 'watchers': [{'id': 81928627, 'username': 'Rodrigo Ceballos Lentini', 'color': '#c51162', 'initials': 'RL', 'email': 'rlentini@clickup.com', 'profilePicture': None}], 'checklists': [], 'tags': [], 'parent': None, 'priority': None, 'due_date': None, 'start_date': None, 'points': None, 'time_estimate': None, 'time_spent': 0, 'custom_fields': [], 'dependencies': [], 'linked_tasks': [], 'team_id': '9011010153', 'url': 'https://app.clickup.com/t/8685mw6dz', 'sharing': {'public': False, 'public_share_expires_on': None, 'public_fields': ['assignees', 'priority', 'due_date', 'content', 'comments', 'attachments', 'customFields', 'subtasks', 'tags', 'checklists', 'coverimage'], 'token': None, 'seo_optimized': False}, 'permission_level': 'create', 'list': {'id': '901100754275', 'name': 'Test List', 'access': True}, 'project': {'id': '90110336890', 'name': 'Test Folder', 'hidden': False, 'access': True}, 'folder': {'id': '90110336890', 'name': 'Test Folder', 'hidden': False, 'access': True}, 'space': {'id': '90110061901'}}\u001b[0m\n",
"Thought:\u001b[32;1m\u001b[1;3m I now have the task created and assigned to Rodrigo\n",
"Final Answer: Rodrigo's user ID is 81928627 and a task called 'Rod's task' has been created and assigned to him.\u001b[0m\n",
"\n",
"\u001b[1m> Finished chain.\u001b[0m\n",
"--------------------------------------------------------------------------------\n"
]
},
{
"data": {
"text/plain": [
"\"Rodrigo's user ID is 81928627 and a task called 'Rod's task' has been created and assigned to him.\""
]
},
"execution_count": 34,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"print_and_run(\"Figure out what user ID Rodrigo is, create a task called 'Rod's task', assign it to Rodrigo\")"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "clickup-copilot",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.9.18"
},
"orig_nbformat": 4
},
"nbformat": 4,
"nbformat_minor": 2
}

@ -0,0 +1,101 @@
from typing import Dict, List
from langchain.agents.agent_toolkits.base import BaseToolkit
from langchain.tools import BaseTool
from langchain.tools.clickup.prompt import (
CLICKUP_FOLDER_CREATE_PROMPT,
CLICKUP_GET_ALL_TEAMS_PROMPT,
CLICKUP_GET_FOLDERS_PROMPT,
CLICKUP_GET_LIST_PROMPT,
CLICKUP_GET_SPACES_PROMPT,
CLICKUP_GET_TASK_ATTRIBUTE_PROMPT,
CLICKUP_GET_TASK_PROMPT,
CLICKUP_LIST_CREATE_PROMPT,
CLICKUP_TASK_CREATE_PROMPT,
CLICKUP_UPDATE_TASK_ASSIGNEE_PROMPT,
CLICKUP_UPDATE_TASK_PROMPT,
)
from langchain.tools.clickup.tool import ClickupAction
from langchain.utilities.clickup import ClickupAPIWrapper
class ClickupToolkit(BaseToolkit):
"""Clickup Toolkit."""
tools: List[BaseTool] = []
@classmethod
def from_clickup_api_wrapper(
cls, clickup_api_wrapper: ClickupAPIWrapper
) -> "ClickupToolkit":
operations: List[Dict] = [
{
"mode": "get_task",
"name": "Get task",
"description": CLICKUP_GET_TASK_PROMPT,
},
{
"mode": "get_task_attribute",
"name": "Get task attribute",
"description": CLICKUP_GET_TASK_ATTRIBUTE_PROMPT,
},
{
"mode": "get_teams",
"name": "Get Teams",
"description": CLICKUP_GET_ALL_TEAMS_PROMPT,
},
{
"mode": "create_task",
"name": "Create Task",
"description": CLICKUP_TASK_CREATE_PROMPT,
},
{
"mode": "create_list",
"name": "Create List",
"description": CLICKUP_LIST_CREATE_PROMPT,
},
{
"mode": "create_folder",
"name": "Create Folder",
"description": CLICKUP_FOLDER_CREATE_PROMPT,
},
{
"mode": "get_list",
"name": "Get all lists in the space",
"description": CLICKUP_GET_LIST_PROMPT,
},
{
"mode": "get_folders",
"name": "Get all folders in the workspace",
"description": CLICKUP_GET_FOLDERS_PROMPT,
},
{
"mode": "get_spaces",
"name": "Get all spaces in the workspace",
"description": CLICKUP_GET_SPACES_PROMPT,
},
{
"mode": "update_task",
"name": "Update task",
"description": CLICKUP_UPDATE_TASK_PROMPT,
},
{
"mode": "update_task_assignees",
"name": "Update task assignees",
"description": CLICKUP_UPDATE_TASK_ASSIGNEE_PROMPT,
},
]
tools = [
ClickupAction(
name=action["name"],
description=action["description"],
mode=action["mode"],
api_wrapper=clickup_api_wrapper,
)
for action in operations
]
return cls(tools=tools)
def get_tools(self) -> List[BaseTool]:
"""Get the tools in the toolkit."""
return self.tools

@ -0,0 +1,131 @@
# flake8: noqa
CLICKUP_TASK_CREATE_PROMPT = """
This tool is a wrapper around clickup's create_task API, useful when you need to create a CLICKUP task.
The input to this tool is a dictionary specifying the fields of the CLICKUP task, and will be passed into clickup's CLICKUP `create_task` function.
Only add fields described by the user.
Use the following mapping in order to map the user's priority to the clickup priority: {{
Urgent = 1,
High = 2,
Normal = 3,
Low = 4,
}}. If the user passes in "urgent" replace the priority value as 1.
Here are a few task descriptions and corresponding input examples:
Task: create a task called "Daily report"
Example Input: {{"name": "Daily report"}}
Task: Make an open task called "ClickUp toolkit refactor" with description "Refactor the clickup toolkit to use dataclasses for parsing", with status "open"
Example Input: {{"name": "ClickUp toolkit refactor", "description": "Refactor the clickup toolkit to use dataclasses for parsing", "status": "Open"}}
Task: create a task with priority 3 called "New Task Name" with description "New Task Description", with status "open"
Example Input: {{"name": "New Task Name", "description": "New Task Description", "status": "Open", "priority": 3}}
Task: Add a task called "Bob's task" and assign it to Bob (user id: 81928627)
Example Input: {{"name": "Bob's task", "description": "Task for Bob", "assignees": [81928627]}}
"""
CLICKUP_LIST_CREATE_PROMPT = """
This tool is a wrapper around clickup's create_list API, useful when you need to create a CLICKUP list.
The input to this tool is a dictionary specifying the fields of a clickup list, and will be passed to clickup's create_list function.
Only add fields described by the user.
Use the following mapping in order to map the user's priority to the clickup priority: {{
Urgent = 1,
High = 2,
Normal = 3,
Low = 4,
}}. If the user passes in "urgent" replace the priority value as 1.
Here are a few list descriptions and corresponding input examples:
Description: make a list with name "General List"
Example Input: {{"name": "General List"}}
Description: add a new list ("TODOs") with low priority
Example Input: {{"name": "General List", "priority": 4}}
Description: create a list with name "List name", content "List content", priority 2, and status "red"
Example Input: {{"name": "List name", "content": "List content", "priority": 2, "status": "red"}}
"""
CLICKUP_FOLDER_CREATE_PROMPT = """
This tool is a wrapper around clickup's create_folder API, useful when you need to create a CLICKUP folder.
The input to this tool is a dictionary specifying the fields of a clickup folder, and will be passed to clickup's create_folder function.
For example, to create a folder with name "Folder name" you would pass in the following dictionary:
{{
"name": "Folder name",
}}
"""
CLICKUP_GET_TASK_PROMPT = """
This tool is a wrapper around clickup's API,
Do NOT use to get a task specific attribute. Use get task attribute instead.
useful when you need to get a specific task for the user. Given the task id you want to create a request similar to the following dictionary:
payload = {{"task_id": "86a0t44tq"}}
"""
CLICKUP_GET_TASK_ATTRIBUTE_PROMPT = """
This tool is a wrapper around clickup's API,
useful when you need to get a specific attribute from a task. Given the task id and desired attribute create a request similar to the following dictionary:
payload = {{"task_id": "<task_id_to_update>", "attribute_name": "<attribute_name_to_update>"}}
Here are some example queries their corresponding payloads:
Get the name of task 23jn23kjn -> {{"task_id": "23jn23kjn", "attribute_name": "name"}}
What is the priority of task 86a0t44tq? -> {{"task_id": "86a0t44tq", "attribute_name": "priority"}}
Output the description of task sdc9ds9jc -> {{"task_id": "sdc9ds9jc", "attribute_name": "description"}}
Who is assigned to task bgjfnbfg0 -> {{"task_id": "bgjfnbfg0", "attribute_name": "assignee"}}
Which is the status of task kjnsdcjc? -> {{"task_id": "kjnsdcjc", "attribute_name": "description"}}
How long is the time estimate of task sjncsd999? -> {{"task_id": "sjncsd999", "attribute_name": "time_estimate"}}
Is task jnsd98sd archived?-> {{"task_id": "jnsd98sd", "attribute_name": "archive"}}
"""
CLICKUP_GET_ALL_TEAMS_PROMPT = """
This tool is a wrapper around clickup's API, useful when you need to get all teams that the user is a part of.
To get a list of all the teams there is no necessary request parameters.
"""
CLICKUP_GET_LIST_PROMPT = """
This tool is a wrapper around clickup's API,
useful when you need to get a specific list for the user. Given the list id you want to create a request similar to the following dictionary:
payload = {{"list_id": "901300608424"}}
"""
CLICKUP_GET_FOLDERS_PROMPT = """
This tool is a wrapper around clickup's API,
useful when you need to get a specific folder for the user. Given the user's workspace id you want to create a request similar to the following dictionary:
payload = {{"folder_id": "90130119692"}}
"""
CLICKUP_GET_SPACES_PROMPT = """
This tool is a wrapper around clickup's API,
useful when you need to get all the spaces available to a user. Given the user's workspace id you want to create a request similar to the following dictionary:
payload = {{"team_id": "90130119692"}}
"""
CLICKUP_GET_SPACES_PROMPT = """
This tool is a wrapper around clickup's API,
useful when you need to get all the spaces available to a user. Given the user's workspace id you want to create a request similar to the following dictionary:
payload = {{"team_id": "90130119692"}}
"""
CLICKUP_UPDATE_TASK_PROMPT = """
This tool is a wrapper around clickup's API,
useful when you need to update a specific attribute of a task. Given the task id, desired attribute to change and the new value you want to create a request similar to the following dictionary:
payload = {{"task_id": "<task_id_to_update>", "attribute_name": "<attribute_name_to_update>", "value": "<value_to_update_to>"}}
Here are some example queries their corresponding payloads:
Change the name of task 23jn23kjn to new task name -> {{"task_id": "23jn23kjn", "attribute_name": "name", "value": "new task name"}}
Update the priority of task 86a0t44tq to 1 -> {{"task_id": "86a0t44tq", "attribute_name": "priority", "value": 1}}
Re-write the description of task sdc9ds9jc to 'a new task description' -> {{"task_id": "sdc9ds9jc", "attribute_name": "description", "value": "a new task description"}}
Forward the status of task kjnsdcjc to done -> {{"task_id": "kjnsdcjc", "attribute_name": "description", "status": "done"}}
Increase the time estimate of task sjncsd999 to 3h -> {{"task_id": "sjncsd999", "attribute_name": "time_estimate", "value": 8000}}
Archive task jnsd98sd -> {{"task_id": "jnsd98sd", "attribute_name": "archive", "value": true}}
*IMPORTANT*: Pay attention to the exact syntax above and the correct use of quotes.
For changing priority and time estimates, we expect integers (int).
For name, description and status we expect strings (str).
For archive, we expect a boolean (bool).
"""
CLICKUP_UPDATE_TASK_ASSIGNEE_PROMPT = """
This tool is a wrapper around clickup's API,
useful when you need to update the assignees of a task. Given the task id, the operation add or remove (rem), and the list of user ids. You want to create a request similar to the following dictionary:
payload = {{"task_id": "<task_id_to_update>", "operation": "<operation, (add or rem)>", "users": [<user_id_1>, <user_id_2>]}}
Here are some example queries their corresponding payloads:
Add 81928627 and 3987234 as assignees to task 21hw21jn -> {{"task_id": "21hw21jn", "operation": "add", "users": [81928627, 3987234]}}
Remove 67823487 as assignee from task jin34ji4 -> {{"task_id": "jin34ji4", "operation": "rem", "users": [67823487]}}
*IMPORTANT*: Users id should always be ints.
"""

@ -0,0 +1,51 @@
"""
This tool allows agents to interact with the clickup library
and operate on a Clickup instance.
To use this tool, you must first set as environment variables:
client_secret
client_id
code
Below is a sample script that uses the Clickup tool:
```python
from langchain.agents import AgentType
from langchain.agents import initialize_agent
from langchain.agents.agent_toolkits.clickup.toolkit import ClickupToolkit
from langchain.llms import OpenAI
from langchain.utilities.clickup import ClickupAPIWrapper
llm = OpenAI(temperature=0)
clickup = ClickupAPIWrapper()
toolkit = ClickupToolkit.from_clickup_api_wrapper(clickup)
agent = initialize_agent(
toolkit.get_tools(),
llm,
agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION,
verbose=True
)
```
"""
from typing import Optional
from langchain.callbacks.manager import CallbackManagerForToolRun
from langchain.pydantic_v1 import Field
from langchain.tools.base import BaseTool
from langchain.utilities.clickup import ClickupAPIWrapper
class ClickupAction(BaseTool):
"""Tool that queries the Clickup API."""
api_wrapper: ClickupAPIWrapper = Field(default_factory=ClickupAPIWrapper)
mode: str
name: str = ""
description: str = ""
def _run(
self,
instructions: str,
run_manager: Optional[CallbackManagerForToolRun] = None,
) -> str:
"""Use the Clickup API to run an operation."""
return self.api_wrapper.run(self.mode, instructions)

@ -0,0 +1,597 @@
"""Util that calls clickup."""
import json
import warnings
from dataclasses import asdict, dataclass, fields
from typing import Any, Dict, List, Mapping, Optional, Tuple, Type, Union
import requests
from langchain.pydantic_v1 import BaseModel, Extra, root_validator
from langchain.utils import get_from_dict_or_env
DEFAULT_URL = "https://api.clickup.com/api/v2"
@dataclass
class Component:
@classmethod
def from_data(cls, data: Dict[str, Any]) -> "Component":
raise NotImplementedError()
@dataclass
class Task(Component):
id: int
name: str
text_content: str
description: str
status: str
creator_id: int
creator_username: str
creator_email: str
assignees: List[Dict[str, Any]]
watchers: List[Dict[str, Any]]
priority: Optional[str]
due_date: Optional[str]
start_date: Optional[str]
points: int
team_id: int
project_id: int
@classmethod
def from_data(cls, data: Dict[str, Any]) -> "Task":
priority = None if data["priority"] is None else data["priority"]["priority"]
return cls(
id=data["id"],
name=data["name"],
text_content=data["text_content"],
description=data["description"],
status=data["status"]["status"],
creator_id=data["creator"]["id"],
creator_username=data["creator"]["username"],
creator_email=data["creator"]["email"],
assignees=data["assignees"],
watchers=data["watchers"],
priority=priority,
due_date=data["due_date"],
start_date=data["start_date"],
points=data["points"],
team_id=data["team_id"],
project_id=data["project"]["id"],
)
@dataclass
class CUList(Component):
folder_id: float
name: str
content: Optional[str] = None
due_date: Optional[int] = None
due_date_time: Optional[bool] = None
priority: Optional[int] = None
assignee: Optional[int] = None
status: Optional[str] = None
@classmethod
def from_data(cls, data: dict) -> "CUList":
return cls(
folder_id=data["folder_id"],
name=data["name"],
content=data.get("content"),
due_date=data.get("due_date"),
due_date_time=data.get("due_date_time"),
priority=data.get("priority"),
assignee=data.get("assignee"),
status=data.get("status"),
)
@dataclass
class Member(Component):
id: int
username: str
email: str
initials: str
@classmethod
def from_data(cls, data: Dict) -> "Member":
return cls(
id=data["user"]["id"],
username=data["user"]["username"],
email=data["user"]["email"],
initials=data["user"]["initials"],
)
@dataclass
class Team(Component):
id: int
name: str
members: List[Member]
@classmethod
def from_data(cls, data: Dict) -> "Team":
members = [Member.from_data(member_data) for member_data in data["members"]]
return cls(id=data["id"], name=data["name"], members=members)
@dataclass
class Space(Component):
id: int
name: str
private: bool
enabled_features: Dict[str, Any]
@classmethod
def from_data(cls, data: Dict[str, Any]) -> "Space":
space_data = data["spaces"][0]
enabled_features = {
feature: value
for feature, value in space_data["features"].items()
if value["enabled"]
}
return cls(
id=space_data["id"],
name=space_data["name"],
private=space_data["private"],
enabled_features=enabled_features,
)
def parse_dict_through_component(
data: dict, component: Type[Component], fault_tolerant: bool = False
) -> Dict:
"""This is a helper function that helps us parse a dictionary by creating
a component and then turning it back into a dictionary. This might seem
silly but it's a nice way to:
1. Extract and format data from a dictionary according to a schema
2. Provide a central place to do this in a fault tolerant way
"""
try:
return asdict(component.from_data(data))
except Exception as e:
if fault_tolerant:
warning_str = f"""Error encountered while trying to parse
{str(data)}: {str(e)}\n Falling back to returning input data."""
warnings.warn(warning_str)
return data
else:
raise e
def extract_dict_elements_from_component_fields(
data: dict, component: Type[Component]
) -> dict:
output = {}
for attribute in fields(component):
if attribute.name in data:
output[attribute.name] = data[attribute.name]
return output
def load_query(
query: str, fault_tolerant: bool = False
) -> Tuple[Optional[Dict], Optional[str]]:
"""
Attempts to parse a JSON string and return the parsed object.
If parsing fails, returns an error message.
:param query: The JSON string to parse.
:return: A tuple containing the parsed object or None and an error message or None.
"""
try:
return json.loads(query), None
except json.JSONDecodeError as e:
if fault_tolerant:
return (
None,
f"""Input must be a valid JSON. Got the following error: {str(e)}.
"Please reformat and try again.""",
)
else:
raise e
def fetch_first_id(data: dict, key: str) -> Optional[int]:
if key in data and len(data[key]) > 0:
if len(data[key]) > 1:
warnings.warn(f"Found multiple {key}: {data[key]}. Defaulting to first.")
return data[key][0]["id"]
return None
def fetch_data(url: str, access_token: str, query: Optional[dict] = None) -> dict:
headers = {"Authorization": access_token}
response = requests.get(url, headers=headers, params=query)
response.raise_for_status()
return response.json()
def fetch_team_id(access_token: str) -> Optional[int]:
url = f"{DEFAULT_URL}/team"
data = fetch_data(url, access_token)
return fetch_first_id(data, "teams")
def fetch_space_id(team_id: int, access_token: str) -> Optional[int]:
url = f"{DEFAULT_URL}/team/{team_id}/space"
data = fetch_data(url, access_token, query={"archived": "false"})
return fetch_first_id(data, "spaces")
def fetch_folder_id(space_id: int, access_token: str) -> Optional[int]:
url = f"{DEFAULT_URL}/space/{space_id}/folder"
data = fetch_data(url, access_token, query={"archived": "false"})
return fetch_first_id(data, "folders")
def fetch_list_id(space_id: int, folder_id: int, access_token: str) -> Optional[int]:
if folder_id:
url = f"{DEFAULT_URL}/folder/{folder_id}/list"
else:
url = f"{DEFAULT_URL}/space/{space_id}/list"
data = fetch_data(url, access_token, query={"archived": "false"})
# The structure to fetch list id differs based if its folderless
if folder_id and "id" in data:
return data["id"]
else:
return fetch_first_id(data, "lists")
class ClickupAPIWrapper(BaseModel):
"""Wrapper for Clickup API."""
access_token: Optional[str] = None
team_id: Optional[str] = None
space_id: Optional[str] = None
folder_id: Optional[str] = None
list_id: Optional[str] = None
class Config:
"""Configuration for this pydantic object."""
extra = Extra.forbid
@classmethod
def get_access_code_url(
cls, oauth_client_id: str, redirect_uri: str = "https://google.com"
) -> str:
url = f"https://app.clickup.com/api?client_id={oauth_client_id}"
return f"{url}&redirect_uri={redirect_uri}"
@classmethod
def get_access_token(
cls, oauth_client_id: str, oauth_client_secret: str, code: str
) -> Optional[str]:
url = f"{DEFAULT_URL}/oauth/token"
params = {
"client_id": oauth_client_id,
"client_secret": oauth_client_secret,
"code": code,
}
response = requests.post(url, params=params)
data = response.json()
if "access_token" not in data:
print(f"Error: {data}")
if "ECODE" in data and data["ECODE"] == "OAUTH_014":
url = ClickupAPIWrapper.get_access_code_url(oauth_client_id)
print(
"You already used this code once. Generate a new one.",
f"Our best guess for the url to get a new code is:\n{url}",
)
return None
return data["access_token"]
@root_validator()
def validate_environment(cls, values: Dict) -> Dict:
"""
Validate that api key and python package exists in environment
"""
values["access_token"] = get_from_dict_or_env(
values, "access_token", "CLICKUP_ACCESS_TOKEN"
)
values["team_id"] = fetch_team_id(values["access_token"])
values["space_id"] = fetch_space_id(values["team_id"], values["access_token"])
values["folder_id"] = fetch_folder_id(
values["space_id"], values["access_token"]
)
values["list_id"] = fetch_list_id(
values["space_id"], values["folder_id"], values["access_token"]
)
return values
def attempt_parse_teams(self, input_dict: dict) -> Dict[str, List[dict]]:
"""
Parse appropriate content from the list of teams
"""
parsed_teams: Dict[str, List[dict]] = {"teams": []}
for team in input_dict["teams"]:
try:
team = parse_dict_through_component(team, Team, fault_tolerant=False)
parsed_teams["teams"].append(team)
except Exception as e:
warnings.warn(f"Error parsing a team {e}")
return parsed_teams
def get_headers(
self,
) -> Mapping[str, Union[str, bytes]]:
if not isinstance(self.access_token, str):
raise TypeError(f"Access Token: {self.access_token}, must be str.")
headers = {
"Authorization": str(self.access_token),
"Content-Type": "application/json",
}
return headers
def get_default_params(self) -> Dict:
return {"archived": "false"}
def get_authorized_teams(self) -> Dict[Any, Any]:
"""
Get all teams for the user
"""
url = f"{DEFAULT_URL}/team"
response = requests.get(url, headers=self.get_headers())
data = response.json()
parsed_teams = self.attempt_parse_teams(data)
return parsed_teams
def get_folders(self) -> Dict:
"""
Get all the folders for the team
"""
url = f"{DEFAULT_URL}/team/" + str(self.team_id) + "/space"
params = self.get_default_params()
response = requests.get(url, headers=self.get_headers(), params=params)
return {"response": response}
def get_task(self, query: str, fault_tolerant: bool = True) -> Dict:
"""
Retrieve a specific task
"""
params, error = load_query(query, fault_tolerant=True)
if params is None:
return {"Error": error}
url = f"{DEFAULT_URL}/task/{params['task_id']}"
params = {
"custom_task_ids": "true",
"team_id": self.team_id,
"include_subtasks": "true",
}
response = requests.get(url, headers=self.get_headers(), params=params)
data = response.json()
parsed_task = parse_dict_through_component(
data, Task, fault_tolerant=fault_tolerant
)
return parsed_task
def get_lists(self) -> Dict:
"""
Get all available lists
"""
url = f"{DEFAULT_URL}/folder/{self.folder_id}/list"
params = self.get_default_params()
response = requests.get(url, headers=self.get_headers(), params=params)
return {"response": response}
def query_tasks(self, query: str) -> Dict:
"""
Query tasks that match certain fields
"""
params, error = load_query(query, fault_tolerant=True)
if params is None:
return {"Error": error}
url = f"{DEFAULT_URL}/list/{params['list_id']}/task"
params = self.get_default_params()
response = requests.get(url, headers=self.get_headers(), params=params)
return {"response": response}
def get_spaces(self) -> Dict:
"""
Get all spaces for the team
"""
url = f"{DEFAULT_URL}/team/{self.team_id}/space"
response = requests.get(
url, headers=self.get_headers(), params=self.get_default_params()
)
data = response.json()
parsed_spaces = parse_dict_through_component(data, Space, fault_tolerant=True)
return parsed_spaces
def get_task_attribute(self, query: str) -> Dict:
"""
Update an attribute of a specified task
"""
task = self.get_task(query, fault_tolerant=True)
params, error = load_query(query, fault_tolerant=True)
if not isinstance(params, dict):
return {"Error": error}
if params["attribute_name"] not in task:
return {
"Error": f"""attribute_name = {params['attribute_name']} was not
found in task keys {task.keys()}. Please call again with one of the key names."""
}
return {params["attribute_name"]: task[params["attribute_name"]]}
def update_task(self, query: str) -> Dict:
"""
Update an attribute of a specified task
"""
query_dict, error = load_query(query, fault_tolerant=True)
if query_dict is None:
return {"Error": error}
url = f"{DEFAULT_URL}/task/{query_dict['task_id']}"
params = {
"custom_task_ids": "true",
"team_id": self.team_id,
"include_subtasks": "true",
}
headers = self.get_headers()
payload = {query_dict["attribute_name"]: query_dict["value"]}
response = requests.put(url, headers=headers, params=params, json=payload)
return {"response": response}
def update_task_assignees(self, query: str) -> Dict:
"""
Add or remove assignees of a specified task
"""
query_dict, error = load_query(query, fault_tolerant=True)
if query_dict is None:
return {"Error": error}
for user in query_dict["users"]:
if not isinstance(user, int):
return {
"Error": f"""All users must be integers, not strings!
"Got user {user} if type {type(user)}"""
}
url = f"{DEFAULT_URL}/task/{query_dict['task_id']}"
headers = self.get_headers()
if query_dict["operation"] == "add":
assigne_payload = {"add": query_dict["users"], "rem": []}
elif query_dict["operation"] == "rem":
assigne_payload = {"add": [], "rem": query_dict["users"]}
else:
raise ValueError(
f"Invalid operation ({query_dict['operation']}). ",
"Valid options ['add', 'rem'].",
)
params = {
"custom_task_ids": "true",
"team_id": self.team_id,
"include_subtasks": "true",
}
payload = {"assignees": assigne_payload}
response = requests.put(url, headers=headers, params=params, json=payload)
return {"response": response}
def create_task(self, query: str) -> Dict:
"""
Creates a new task
"""
query_dict, error = load_query(query, fault_tolerant=True)
if query_dict is None:
return {"Error": error}
list_id = self.list_id
url = f"{DEFAULT_URL}/list/{list_id}/task"
params = {"custom_task_ids": "true", "team_id": self.team_id}
payload = extract_dict_elements_from_component_fields(query_dict, Task)
headers = self.get_headers()
response = requests.post(url, json=payload, headers=headers, params=params)
data: Dict = response.json()
return parse_dict_through_component(data, Task, fault_tolerant=True)
def create_list(self, query: str) -> Dict:
"""
Creates a new list
"""
query_dict, error = load_query(query, fault_tolerant=True)
if query_dict is None:
return {"Error": error}
# Default to using folder as location if it exists.
# If not, fall back to using the space.
location = self.folder_id if self.folder_id else self.space_id
url = f"{DEFAULT_URL}/folder/{location}/list"
payload = extract_dict_elements_from_component_fields(query_dict, Task)
headers = self.get_headers()
response = requests.post(url, json=payload, headers=headers)
data = response.json()
parsed_list = parse_dict_through_component(data, CUList, fault_tolerant=True)
# set list id to new list
if "id" in parsed_list:
self.list_id = parsed_list["id"]
return parsed_list
def create_folder(self, query: str) -> Dict:
"""
Creates a new folder
"""
query_dict, error = load_query(query, fault_tolerant=True)
if query_dict is None:
return {"Error": error}
space_id = self.space_id
url = f"{DEFAULT_URL}/space/{space_id}/folder"
payload = {
"name": query_dict["name"],
}
headers = self.get_headers()
response = requests.post(url, json=payload, headers=headers)
data = response.json()
if "id" in data:
self.list_id = data["id"]
return data
def run(self, mode: str, query: str) -> str:
if mode == "get_task":
output = self.get_task(query)
elif mode == "get_task_attribute":
output = self.get_task_attribute(query)
elif mode == "get_teams":
output = self.get_authorized_teams()
elif mode == "create_task":
output = self.create_task(query)
elif mode == "create_list":
output = self.create_list(query)
elif mode == "create_folder":
output = self.create_folder(query)
elif mode == "get_lists":
output = self.get_lists()
elif mode == "get_folders":
output = self.get_folders()
elif mode == "get_spaces":
output = self.get_spaces()
elif mode == "update_task":
output = self.update_task(query)
elif mode == "update_task_assignees":
output = self.update_task_assignees(query)
else:
output = {"ModeError": f"Got unexpected mode {mode}."}
try:
return json.dumps(output)
except Exception:
return str(output)

@ -1,6 +1,6 @@
# openai
# your api key from https://platform.openai.com/account/api-keys
OPENAI_API_KEY=
OPENAI_API_KEY=your_openai_api_key_here
# searchapi
@ -22,6 +22,8 @@ PINECONE_ENVIRONMENT=us-west4-gcp
# JIRA_USERNAME=your_jira_username_here
# JIRA_INSTANCE_URL=your_jira_instance_url_here
# clickup
CLICKUP_ACCESS_TOKEN=your_clickup_access_token
# power bi
# sign in to azure in order to authenticate with DefaultAzureCredentials

@ -0,0 +1,88 @@
"""Integration test for JIRA API Wrapper."""
import json
from datetime import datetime
import pytest
from langchain.utilities.clickup import ClickupAPIWrapper
@pytest.fixture
def clickup_wrapper() -> ClickupAPIWrapper:
return ClickupAPIWrapper()
def test_init(clickup_wrapper: ClickupAPIWrapper) -> None:
assert isinstance(clickup_wrapper, ClickupAPIWrapper)
def test_get_access_code_url() -> None:
assert isinstance(
ClickupAPIWrapper.get_access_code_url("oauth_client_id", "oauth_client_secret"),
str,
)
def test_get_access_token() -> None:
output = ClickupAPIWrapper.get_access_token(
"oauth_client_id", "oauth_client_secret", "code"
)
assert output is None
def test_folder_related(clickup_wrapper: ClickupAPIWrapper) -> None:
time_str = datetime.now().strftime("%d/%m/%Y-%H:%M:%S")
task_name = f"Test Folder - {time_str}"
# Create Folder
create_response = json.loads(
clickup_wrapper.run(mode="create_folder", query=json.dumps({"name": task_name}))
)
assert create_response["name"] == task_name
def test_list_related(clickup_wrapper: ClickupAPIWrapper) -> None:
time_str = datetime.now().strftime("%d/%m/%Y-%H:%M:%S")
task_name = f"Test List - {time_str}"
# Create List
create_response = json.loads(
clickup_wrapper.run(mode="create_list", query=json.dumps({"name": task_name}))
)
assert create_response["name"] == task_name
def test_task_related(clickup_wrapper: ClickupAPIWrapper) -> None:
time_str = datetime.now().strftime("%d/%m/%Y-%H:%M:%S")
task_name = f"Test Task - {time_str}"
# Create task
create_response = json.loads(
clickup_wrapper.run(
mode="create_task",
query=json.dumps({"name": task_name, "description": "This is a Test"}),
)
)
assert create_response["name"] == task_name
# Get task
task_id = create_response["id"]
get_response = json.loads(
clickup_wrapper.run(mode="get_task", query=json.dumps({"task_id": task_id}))
)
assert get_response["name"] == task_name
# Update task
new_name = f"{task_name} - New"
clickup_wrapper.run(
mode="update_task",
query=json.dumps(
{"task_id": task_id, "attribute_name": "name", "value": new_name}
),
)
get_response_2 = json.loads(
clickup_wrapper.run(mode="get_task", query=json.dumps({"task_id": task_id}))
)
assert get_response_2["name"] == new_name
Loading…
Cancel
Save