From b140d366e3a4c182f731992df55a341533583bec Mon Sep 17 00:00:00 2001 From: Harrison Chase Date: Mon, 17 Apr 2023 21:14:40 -0700 Subject: [PATCH] Harrison/jira (#3055) Co-authored-by: William Li <32046231+zywilliamli@users.noreply.github.com> Co-authored-by: William Li --- docs/modules/agents/tools/examples/jira.ipynb | 167 ++++++++++++++++ langchain/agents/agent_toolkits/__init__.py | 2 + .../agents/agent_toolkits/jira/__init__.py | 1 + .../agents/agent_toolkits/jira/toolkit.py | 31 +++ langchain/tools/jira/__init__.py | 1 + langchain/tools/jira/prompt.py | 34 ++++ langchain/tools/jira/tool.py | 49 +++++ langchain/utilities/jira.py | 180 ++++++++++++++++++ 8 files changed, 465 insertions(+) create mode 100644 docs/modules/agents/tools/examples/jira.ipynb create mode 100644 langchain/agents/agent_toolkits/jira/__init__.py create mode 100644 langchain/agents/agent_toolkits/jira/toolkit.py create mode 100644 langchain/tools/jira/__init__.py create mode 100644 langchain/tools/jira/prompt.py create mode 100644 langchain/tools/jira/tool.py create mode 100644 langchain/utilities/jira.py diff --git a/docs/modules/agents/tools/examples/jira.ipynb b/docs/modules/agents/tools/examples/jira.ipynb new file mode 100644 index 00000000..0ba8c35c --- /dev/null +++ b/docs/modules/agents/tools/examples/jira.ipynb @@ -0,0 +1,167 @@ +{ + "cells": [ + { + "attachments": {}, + "cell_type": "markdown", + "id": "245a954a", + "metadata": {}, + "source": [ + "# Jira\n", + "\n", + "This notebook goes over how to use the Jira tool.\n", + "The Jira tool allows agents to interact with a given Jira instance, performing actions such as searching for issues and creating issues, the tool wraps the atlassian-python-api library, for more see: https://atlassian-python-api.readthedocs.io/jira.html\n", + "\n", + "To use this tool, you must first set as environment variables:\n", + " JIRA_API_TOKEN\n", + " JIRA_USERNAME\n", + " JIRA_INSTANCE_URL" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "961b3689", + "metadata": { + "vscode": { + "languageId": "shellscript" + }, + "ExecuteTime": { + "start_time": "2023-04-17T10:21:18.698672Z", + "end_time": "2023-04-17T10:21:20.168639Z" + } + }, + "outputs": [], + "source": [ + "%pip install atlassian-python-api" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "34bb5968", + "metadata": { + "ExecuteTime": { + "start_time": "2023-04-17T10:21:22.911233Z", + "end_time": "2023-04-17T10:21:23.730922Z" + } + }, + "outputs": [], + "source": [ + "import os\n", + "from langchain.agents import AgentType\n", + "from langchain.agents import initialize_agent\n", + "from langchain.agents.agent_toolkits.jira.toolkit import JiraToolkit\n", + "from langchain.llms import OpenAI\n", + "from langchain.utilities.jira import JiraAPIWrapper" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "outputs": [], + "source": [ + "os.environ[\"JIRA_API_TOKEN\"] = \"abc\"\n", + "os.environ[\"JIRA_USERNAME\"] = \"123\"\n", + "os.environ[\"JIRA_INSTANCE_URL\"] = \"https://jira.atlassian.com\"\n", + "os.environ[\"OPENAI_API_KEY\"] = \"xyz\"" + ], + "metadata": { + "collapsed": false, + "ExecuteTime": { + "start_time": "2023-04-17T10:22:42.499447Z", + "end_time": "2023-04-17T10:22:42.505412Z" + } + } + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "ac4910f8", + "metadata": { + "ExecuteTime": { + "start_time": "2023-04-17T10:22:44.664481Z", + "end_time": "2023-04-17T10:22:44.720538Z" + } + }, + "outputs": [], + "source": [ + "llm = OpenAI(temperature=0)\n", + "jira = JiraAPIWrapper()\n", + "toolkit = JiraToolkit.from_jira_api_wrapper(jira)\n", + "agent = initialize_agent(\n", + " toolkit.get_tools(),\n", + " llm,\n", + " agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION,\n", + " verbose=True\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "\n", + "\u001B[1m> Entering new AgentExecutor chain...\u001B[0m\n", + "\u001B[32;1m\u001B[1;3m I need to create an issue in project PW\n", + "Action: Create Issue\n", + "Action Input: {\"summary\": \"Make more fried rice\", \"description\": \"Reminder to make more fried rice\", \"issuetype\": {\"name\": \"Task\"}, \"priority\": {\"name\": \"Low\"}, \"project\": {\"key\": \"PW\"}}\u001B[0m\n", + "Observation: \u001B[38;5;200m\u001B[1;3mNone\u001B[0m\n", + "Thought:\u001B[32;1m\u001B[1;3m I now know the final answer\n", + "Final Answer: A new issue has been created in project PW with the summary \"Make more fried rice\" and description \"Reminder to make more fried rice\".\u001B[0m\n", + "\n", + "\u001B[1m> Finished chain.\u001B[0m\n" + ] + }, + { + "data": { + "text/plain": "'A new issue has been created in project PW with the summary \"Make more fried rice\" and description \"Reminder to make more fried rice\".'" + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "agent.run(\"make a new issue in project PW to remind me to make more fried rice\")" + ], + "metadata": { + "collapsed": false, + "ExecuteTime": { + "start_time": "2023-04-17T10:23:33.662454Z", + "end_time": "2023-04-17T10:23:38.121883Z" + } + } + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "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.7" + }, + "vscode": { + "interpreter": { + "hash": "53f3bc57609c7a84333bb558594977aa5b4026b1d6070b93987956689e367341" + } + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/langchain/agents/agent_toolkits/__init__.py b/langchain/agents/agent_toolkits/__init__.py index d944a6b2..b446a506 100644 --- a/langchain/agents/agent_toolkits/__init__.py +++ b/langchain/agents/agent_toolkits/__init__.py @@ -1,6 +1,7 @@ """Agent toolkits.""" from langchain.agents.agent_toolkits.csv.base import create_csv_agent +from langchain.agents.agent_toolkits.jira.toolkit import JiraToolkit from langchain.agents.agent_toolkits.json.base import create_json_agent from langchain.agents.agent_toolkits.json.toolkit import JsonToolkit from langchain.agents.agent_toolkits.nla.toolkit import NLAToolkit @@ -38,4 +39,5 @@ __all__ = [ "create_pandas_dataframe_agent", "create_csv_agent", "ZapierToolkit", + "JiraToolkit", ] diff --git a/langchain/agents/agent_toolkits/jira/__init__.py b/langchain/agents/agent_toolkits/jira/__init__.py new file mode 100644 index 00000000..9f7c6755 --- /dev/null +++ b/langchain/agents/agent_toolkits/jira/__init__.py @@ -0,0 +1 @@ +"""Jira Toolkit.""" diff --git a/langchain/agents/agent_toolkits/jira/toolkit.py b/langchain/agents/agent_toolkits/jira/toolkit.py new file mode 100644 index 00000000..86f09a4f --- /dev/null +++ b/langchain/agents/agent_toolkits/jira/toolkit.py @@ -0,0 +1,31 @@ +"""Jira Toolkit.""" +from typing import List + +from langchain.agents.agent_toolkits.base import BaseToolkit +from langchain.tools import BaseTool +from langchain.tools.jira.tool import JiraAction +from langchain.utilities.jira import JiraAPIWrapper + + +class JiraToolkit(BaseToolkit): + """Jira Toolkit.""" + + tools: List[BaseTool] = [] + + @classmethod + def from_jira_api_wrapper(cls, jira_api_wrapper: JiraAPIWrapper) -> "JiraToolkit": + actions = jira_api_wrapper.list() + tools = [ + JiraAction( + name=action["name"], + description=action["description"], + mode=action["mode"], + api_wrapper=jira_api_wrapper, + ) + for action in actions + ] + return cls(tools=tools) + + def get_tools(self) -> List[BaseTool]: + """Get the tools in the toolkit.""" + return self.tools diff --git a/langchain/tools/jira/__init__.py b/langchain/tools/jira/__init__.py new file mode 100644 index 00000000..ef6b8a2a --- /dev/null +++ b/langchain/tools/jira/__init__.py @@ -0,0 +1 @@ +"""Zapier Tool.""" diff --git a/langchain/tools/jira/prompt.py b/langchain/tools/jira/prompt.py new file mode 100644 index 00000000..eb97b818 --- /dev/null +++ b/langchain/tools/jira/prompt.py @@ -0,0 +1,34 @@ +# flake8: noqa +JIRA_ISSUE_CREATE_PROMPT = """ + This tool is a wrapper around atlassian-python-api's Jira issue_create API, useful when you need to create a Jira issue. + The input to this tool is a dictionary specifying the fields of the Jira issue, and will be passed into atlassian-python-api's Jira `issue_create` function. + For example, to create a low priority task called "test issue" with description "test description", you would pass in the following dictionary: + {{"summary": "test issue", "description": "test description", "issuetype": {{"name": "Task"}}, "priority": {{"name": "Low"}}}} + """ + +JIRA_GET_ALL_PROJECTS_PROMPT = """ + This tool is a wrapper around atlassian-python-api's Jira project API, + useful when you need to fetch all the projects the user has access to, find out how many projects there are, or as an intermediary step that involv searching by projects. + there is no input to this tool. + """ + +JIRA_JQL_PROMPT = """ + This tool is a wrapper around atlassian-python-api's Jira jql API, useful when you need to search for Jira issues. + The input to this tool is a JQL query string, and will be passed into atlassian-python-api's Jira `jql` function, + For example, to find all the issues in project "Test" assigned to the me, you would pass in the following string: + project = Test AND assignee = currentUser() + or to find issues with summaries that contain the word "test", you would pass in the following string: + summary ~ 'test' + """ + +JIRA_CATCH_ALL_PROMPT = """ + This tool is a wrapper around atlassian-python-api's Jira API. + There are other dedicated tools for fetching all projects, and creating and searching for issues, + use this tool if you need to perform any other actions allowed by the atlassian-python-api Jira API. + The input to this tool is line of python code that calls a function from atlassian-python-api's Jira API + For example, to update the summary field of an issue, you would pass in the following string: + self.jira.update_issue_field(key, {{"summary": "New summary"}}) + or to find out how many projects are in the Jira instance, you would pass in the following string: + self.jira.projects() + For more information on the Jira API, refer to https://atlassian-python-api.readthedocs.io/jira.html + """ diff --git a/langchain/tools/jira/tool.py b/langchain/tools/jira/tool.py new file mode 100644 index 00000000..86861759 --- /dev/null +++ b/langchain/tools/jira/tool.py @@ -0,0 +1,49 @@ +""" +This tool allows agents to interact with the atlassian-python-api library +and operate on a Jira instance. For more information on the +atlassian-python-api library, see https://atlassian-python-api.readthedocs.io/jira.html + +To use this tool, you must first set as environment variables: + JIRA_API_TOKEN + JIRA_USERNAME + JIRA_INSTANCE_URL + +Below is a sample script that uses the Jira tool: + +```python +from langchain.agents import AgentType +from langchain.agents import initialize_agent +from langchain.agents.agent_toolkits.jira.toolkit import JiraToolkit +from langchain.llms import OpenAI +from langchain.utilities.jira import JiraAPIWrapper + +llm = OpenAI(temperature=0) +jira = JiraAPIWrapper() +toolkit = JiraToolkit.from_jira_api_wrapper(jira) +agent = initialize_agent( + toolkit.get_tools(), + llm, + agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION, + verbose=True +) +``` +""" +from pydantic import Field + +from langchain.tools.base import BaseTool +from langchain.utilities.jira import JiraAPIWrapper + + +class JiraAction(BaseTool): + api_wrapper: JiraAPIWrapper = Field(default_factory=JiraAPIWrapper) + mode: str + name = "" + description = "" + + def _run(self, instructions: str) -> str: + """Use the Atlassian Jira API to run an operation.""" + return self.api_wrapper.run(self.mode, instructions) + + async def _arun(self, _: str) -> str: + """Use the Atlassian Jira API to run an operation.""" + raise NotImplementedError("JiraAction does not support async") diff --git a/langchain/utilities/jira.py b/langchain/utilities/jira.py new file mode 100644 index 00000000..e7f5596b --- /dev/null +++ b/langchain/utilities/jira.py @@ -0,0 +1,180 @@ +"""Util that calls Jira.""" +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Extra, root_validator + +from langchain.tools.jira.prompt import ( + JIRA_CATCH_ALL_PROMPT, + JIRA_GET_ALL_PROJECTS_PROMPT, + JIRA_ISSUE_CREATE_PROMPT, + JIRA_JQL_PROMPT, +) +from langchain.utils import get_from_dict_or_env + + +# TODO: think about error handling, more specific api specs, and jql/project limits +class JiraAPIWrapper(BaseModel): + """Wrapper for Jira API.""" + + jira: Any #: :meta private: + jira_username: Optional[str] = None + jira_api_token: Optional[str] = None + jira_instance_url: Optional[str] = None + + operations: List[Dict] = [ + { + "mode": "jql", + "name": "JQL Query", + "description": JIRA_JQL_PROMPT, + }, + { + "mode": "get_projects", + "name": "Get Projects", + "description": JIRA_GET_ALL_PROJECTS_PROMPT, + }, + { + "mode": "create_issue", + "name": "Create Issue", + "description": JIRA_ISSUE_CREATE_PROMPT, + }, + { + "mode": "other", + "name": "Catch all Jira API call", + "description": JIRA_CATCH_ALL_PROMPT, + }, + ] + + class Config: + """Configuration for this pydantic object.""" + + extra = Extra.forbid + + def list(self) -> List[Dict]: + return self.operations + + @root_validator() + def validate_environment(cls, values: Dict) -> Dict: + """Validate that api key and python package exists in environment.""" + jira_username = get_from_dict_or_env(values, "jira_username", "JIRA_USERNAME") + values["jira_username"] = jira_username + + jira_api_token = get_from_dict_or_env( + values, "jira_api_token", "JIRA_API_TOKEN" + ) + values["jira_api_token"] = jira_api_token + + jira_instance_url = get_from_dict_or_env( + values, "jira_instance_url", "JIRA_INSTANCE_URL" + ) + values["jira_instance_url"] = jira_instance_url + + try: + from atlassian import Jira + except ImportError: + raise ImportError( + "atlassian-python-api is not installed. " + "Please install it with `pip install atlassian-python-api`" + ) + + jira = Jira( + url=jira_instance_url, + username=jira_username, + password=jira_api_token, + cloud=True, + ) + values["jira"] = jira + + return values + + def parse_issues(self, issues: Dict) -> List[dict]: + parsed = [] + for issue in issues["issues"]: + key = issue["key"] + summary = issue["fields"]["summary"] + created = issue["fields"]["created"][0:10] + priority = issue["fields"]["priority"]["name"] + status = issue["fields"]["status"]["name"] + try: + assignee = issue["fields"]["assignee"]["displayName"] + except Exception: + assignee = "None" + rel_issues = {} + for related_issue in issue["fields"]["issuelinks"]: + if "inwardIssue" in related_issue.keys(): + rel_type = related_issue["type"]["inward"] + rel_key = related_issue["inwardIssue"]["key"] + rel_summary = related_issue["inwardIssue"]["fields"]["summary"] + if "outwardIssue" in related_issue.keys(): + rel_type = related_issue["type"]["outward"] + rel_key = related_issue["outwardIssue"]["key"] + rel_summary = related_issue["outwardIssue"]["fields"]["summary"] + rel_issues = {"type": rel_type, "key": rel_key, "summary": rel_summary} + parsed.append( + { + "key": key, + "summary": summary, + "created": created, + "assignee": assignee, + "priority": priority, + "status": status, + "related_issues": rel_issues, + } + ) + return parsed + + def parse_projects(self, projects: List[dict]) -> List[dict]: + parsed = [] + for project in projects: + id = project["id"] + key = project["key"] + name = project["name"] + type = project["projectTypeKey"] + style = project["style"] + parsed.append( + {"id": id, "key": key, "name": name, "type": type, "style": style} + ) + return parsed + + def search(self, query: str) -> str: + issues = self.jira.jql(query) + parsed_issues = self.parse_issues(issues) + parsed_issues_str = ( + "Found " + str(len(parsed_issues)) + " issues:\n" + str(parsed_issues) + ) + return parsed_issues_str + + def project(self) -> str: + projects = self.jira.projects() + parsed_projects = self.parse_projects(projects) + parsed_projects_str = ( + "Found " + str(len(parsed_projects)) + " projects:\n" + str(parsed_projects) + ) + return parsed_projects_str + + def create(self, query: str) -> str: + try: + import json + except ImportError: + raise ImportError( + "json is not installed. " "Please install it with `pip install json`" + ) + params = json.loads(query) + return self.jira.create_issue(fields=dict(params)) + + def other(self, query: str) -> str: + context = {"self": self} + exec(f"result = {query}", context) + result = context["result"] + return str(result) + + def run(self, mode: str, query: str) -> str: + if mode == "jql": + return self.search(query) + elif mode == "get_projects": + return self.project() + elif mode == "create_issue": + return self.create(query) + elif mode == "other": + return self.other(query) + else: + raise ValueError(f"Got unexpected mode {mode}")