diff --git a/docs/docs/integrations/tools/jira.ipynb b/docs/docs/integrations/tools/jira.ipynb index 045cd5d2d13..e424a090a89 100644 --- a/docs/docs/integrations/tools/jira.ipynb +++ b/docs/docs/integrations/tools/jira.ipynb @@ -14,10 +14,12 @@ "## Installation and setup\n", "\n", "To use this tool, you must first set as environment variables:\n", - " JIRA_API_TOKEN\n", - " JIRA_USERNAME\n", - " JIRA_INSTANCE_URL\n", - " JIRA_CLOUD" + " JIRA_INSTANCE_URL,\n", + " JIRA_CLOUD\n", + "\n", + "You have the choice between two authentication methods:\n", + "- API token authentication: set the JIRA_API_TOKEN (and JIRA_USERNAME if needed) environment variables\n", + "- OAuth2.0 authentication: set the JIRA_OAUTH2 environment variable as a dict having as fields \"client_id\" and \"token\" which is a dict containing at least \"access_token\" and \"token_type\"" ] }, { @@ -79,6 +81,12 @@ "from langchain_openai import OpenAI" ] }, + { + "cell_type": "markdown", + "id": "3c925f1468696e4c", + "metadata": {}, + "source": "For authentication with API token" + }, { "cell_type": "code", "execution_count": 3, @@ -109,6 +117,27 @@ "os.environ[\"JIRA_CLOUD\"] = \"True\"" ] }, + { + "cell_type": "markdown", + "id": "325ea81fb416aac6", + "metadata": {}, + "source": "For authentication with a Oauth2.0" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "917e83e3a764d91a", + "metadata": {}, + "outputs": [], + "source": [ + "os.environ[\"JIRA_OAUTH2\"] = (\n", + " '{\"client_id\": \"123\", \"token\": {\"access_token\": \"abc\", \"token_type\": \"bearer\"}}'\n", + ")\n", + "os.environ[\"JIRA_INSTANCE_URL\"] = \"https://jira.atlassian.com\"\n", + "os.environ[\"OPENAI_API_KEY\"] = \"xyz\"\n", + "os.environ[\"JIRA_CLOUD\"] = \"True\"" + ] + }, { "cell_type": "code", "execution_count": 4, @@ -215,15 +244,15 @@ "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", + "\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", + "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" + "\u001B[1m> Finished chain.\u001B[0m\n" ] }, { diff --git a/libs/community/langchain_community/tools/jira/prompt.py b/libs/community/langchain_community/tools/jira/prompt.py index 08f06023040..4e47048aa34 100644 --- a/libs/community/langchain_community/tools/jira/prompt.py +++ b/libs/community/langchain_community/tools/jira/prompt.py @@ -8,7 +8,7 @@ JIRA_ISSUE_CREATE_PROMPT = """ 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. + 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 involve searching by projects. there is no input to this tool. """ diff --git a/libs/community/langchain_community/utilities/jira.py b/libs/community/langchain_community/utilities/jira.py index ad8258e363d..fb8fdc11c8f 100644 --- a/libs/community/langchain_community/utilities/jira.py +++ b/libs/community/langchain_community/utilities/jira.py @@ -1,19 +1,51 @@ """Util that calls Jira.""" -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Union from langchain_core.utils import get_from_dict_or_env from pydantic import BaseModel, ConfigDict, model_validator +from typing_extensions import TypedDict + + +class JiraOauth2Token(TypedDict): + """Jira OAuth2 token.""" + + access_token: str + """Jira OAuth2 access token.""" + token_type: str + """Jira OAuth2 token type ('bearer' or other).""" + + +class JiraOauth2(TypedDict): + """Jira OAuth2.""" + + client_id: str + """Jira OAuth2 client ID.""" + token: JiraOauth2Token + """Jira OAuth2 token.""" # TODO: think about error handling, more specific api specs, and jql/project limits class JiraAPIWrapper(BaseModel): - """Wrapper for Jira API.""" + """ + Wrapper for Jira API. You can connect to Jira with either an API token or OAuth2. + - with API token, you need to provide the JIRA_USERNAME and JIRA_API_TOKEN + environment variables or arguments. + ex: JIRA_USERNAME=your_username JIRA_API_TOKEN=your_api_token + - with OAuth2, you need to provide the JIRA_OAUTH2 environment variable or + argument as a dict having as fields "client_id" and "token" which is + a dict containing at least "access_token" and "token_type". + ex: JIRA_OAUTH2='{"client_id": "your_client_id", "token": + {"access_token": "your_access_token","token_type": "bearer"}}' + """ jira: Any = None #: :meta private: confluence: Any = None jira_username: Optional[str] = None jira_api_token: Optional[str] = None + """Jira API token when you choose to connect to Jira with api token.""" + jira_oauth2: Optional[Union[JiraOauth2, str]] = None + """Jira OAuth2 token when you choose to connect to Jira with oauth2.""" jira_instance_url: Optional[str] = None jira_cloud: Optional[bool] = None @@ -31,10 +63,30 @@ class JiraAPIWrapper(BaseModel): 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", default="" ) values["jira_api_token"] = jira_api_token + jira_oauth2 = get_from_dict_or_env( + values, "jira_oauth2", "JIRA_OAUTH2", default="" + ) + values["jira_oauth2"] = jira_oauth2 + + if jira_oauth2 and isinstance(jira_oauth2, str): + try: + import json + + jira_oauth2 = json.loads(jira_oauth2) + except ImportError: + raise ImportError( + "json is not installed. Please install it with `pip install json`" + ) + except json.decoder.JSONDecodeError as e: + raise ValueError( + f"The format of the JIRA_OAUTH2 string is " + f"not a valid dictionary: {e}" + ) + jira_instance_url = get_from_dict_or_env( values, "jira_instance_url", "JIRA_INSTANCE_URL" ) @@ -47,6 +99,12 @@ class JiraAPIWrapper(BaseModel): jira_cloud = jira_cloud_str.lower() == "true" values["jira_cloud"] = jira_cloud + if jira_api_token and jira_oauth2: + raise ValueError( + "You have to provide either a jira_api_token or a jira_oauth2. " + "Not both." + ) + try: from atlassian import Confluence, Jira except ImportError: @@ -55,26 +113,38 @@ class JiraAPIWrapper(BaseModel): "Please install it with `pip install atlassian-python-api`" ) - if jira_username == "": - jira = Jira( - url=jira_instance_url, - token=jira_api_token, - cloud=jira_cloud, - ) - else: - jira = Jira( + if jira_api_token: + if jira_username == "": + jira = Jira( + url=jira_instance_url, + token=jira_api_token, + cloud=jira_cloud, + ) + else: + jira = Jira( + url=jira_instance_url, + username=jira_username, + password=jira_api_token, + cloud=jira_cloud, + ) + + confluence = Confluence( url=jira_instance_url, username=jira_username, password=jira_api_token, cloud=jira_cloud, ) - - confluence = Confluence( - url=jira_instance_url, - username=jira_username, - password=jira_api_token, - cloud=jira_cloud, - ) + elif jira_oauth2: + jira = Jira( + url=jira_instance_url, + oauth2=jira_oauth2, + cloud=jira_cloud, + ) + confluence = Confluence( + url=jira_instance_url, + oauth2=jira_oauth2, + cloud=jira_cloud, + ) values["jira"] = jira values["confluence"] = confluence @@ -97,7 +167,7 @@ class JiraAPIWrapper(BaseModel): except Exception: assignee = "None" rel_issues = {} - for related_issue in issue["fields"]["issuelinks"]: + for related_issue in issue["fields"].get("issuelinks", []): if "inwardIssue" in related_issue.keys(): rel_type = related_issue["type"]["inward"] rel_key = related_issue["inwardIssue"]["key"] @@ -126,8 +196,8 @@ class JiraAPIWrapper(BaseModel): id = project["id"] key = project["key"] name = project["name"] - type = project["projectTypeKey"] - style = project.get("style", None) + type = project.get("projectTypeKey") + style = project.get("style") parsed.append( {"id": id, "key": key, "name": name, "type": type, "style": style} ) diff --git a/libs/community/tests/unit_tests/jira/test_jira_api_wrapper.py b/libs/community/tests/unit_tests/jira/test_jira_api_wrapper.py index ac73f3f5f51..68c66b9e404 100644 --- a/libs/community/tests/unit_tests/jira/test_jira_api_wrapper.py +++ b/libs/community/tests/unit_tests/jira/test_jira_api_wrapper.py @@ -1,3 +1,4 @@ +import json from unittest.mock import MagicMock, patch import pytest @@ -60,3 +61,24 @@ class TestJiraAPIWrapper: password="test_token", cloud=False, ) + + def test_jira_api_wrapper_with_oauth_dict(self, mock_jira: MagicMock) -> None: + oauth_dict = { + "client_id": "test_client_id", + "token": { + "access_token": "test_access_token", + "token_type": "test_token_type", + }, + } + oauth_string = json.dumps(oauth_dict) + + JiraAPIWrapper( + jira_oauth2=oauth_string, + jira_instance_url="https://test.atlassian.net", + jira_cloud=False, + ) + mock_jira.assert_called_once_with( + url="https://test.atlassian.net", + oauth2={"client": None, **oauth_dict}, + cloud=False, + )