mirror of
https://github.com/hwchase17/langchain.git
synced 2025-06-28 09:28:48 +00:00
feat(community): add oauth2 support for Jira toolkit (#30684)
**Description:** add support for oauth2 in Jira tool by adding the possibility to pass a dictionary with oauth parameters. I also adapted the documentation to show this new behavior
This commit is contained in:
parent
b6fe7e8c10
commit
48cf7c838d
@ -14,10 +14,12 @@
|
|||||||
"## Installation and setup\n",
|
"## Installation and setup\n",
|
||||||
"\n",
|
"\n",
|
||||||
"To use this tool, you must first set as environment variables:\n",
|
"To use this tool, you must first set as environment variables:\n",
|
||||||
" JIRA_API_TOKEN\n",
|
" JIRA_INSTANCE_URL,\n",
|
||||||
" JIRA_USERNAME\n",
|
" JIRA_CLOUD\n",
|
||||||
" JIRA_INSTANCE_URL\n",
|
"\n",
|
||||||
" JIRA_CLOUD"
|
"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"
|
"from langchain_openai import OpenAI"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "3c925f1468696e4c",
|
||||||
|
"metadata": {},
|
||||||
|
"source": "For authentication with API token"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": 3,
|
"execution_count": 3,
|
||||||
@ -109,6 +117,27 @@
|
|||||||
"os.environ[\"JIRA_CLOUD\"] = \"True\""
|
"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",
|
"cell_type": "code",
|
||||||
"execution_count": 4,
|
"execution_count": 4,
|
||||||
@ -215,15 +244,15 @@
|
|||||||
"text": [
|
"text": [
|
||||||
"\n",
|
"\n",
|
||||||
"\n",
|
"\n",
|
||||||
"\u001b[1m> Entering new AgentExecutor chain...\u001b[0m\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[32;1m\u001B[1;3m I need to create an issue in project PW\n",
|
||||||
"Action: Create Issue\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",
|
"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",
|
"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",
|
"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",
|
"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",
|
"\n",
|
||||||
"\u001b[1m> Finished chain.\u001b[0m\n"
|
"\u001B[1m> Finished chain.\u001B[0m\n"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -8,7 +8,7 @@ JIRA_ISSUE_CREATE_PROMPT = """
|
|||||||
|
|
||||||
JIRA_GET_ALL_PROJECTS_PROMPT = """
|
JIRA_GET_ALL_PROJECTS_PROMPT = """
|
||||||
This tool is a wrapper around atlassian-python-api's Jira project API,
|
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.
|
there is no input to this tool.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@ -1,19 +1,51 @@
|
|||||||
"""Util that calls Jira."""
|
"""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 langchain_core.utils import get_from_dict_or_env
|
||||||
from pydantic import BaseModel, ConfigDict, model_validator
|
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
|
# TODO: think about error handling, more specific api specs, and jql/project limits
|
||||||
class JiraAPIWrapper(BaseModel):
|
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:
|
jira: Any = None #: :meta private:
|
||||||
confluence: Any = None
|
confluence: Any = None
|
||||||
jira_username: Optional[str] = None
|
jira_username: Optional[str] = None
|
||||||
jira_api_token: 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_instance_url: Optional[str] = None
|
||||||
jira_cloud: Optional[bool] = None
|
jira_cloud: Optional[bool] = None
|
||||||
|
|
||||||
@ -31,10 +63,30 @@ class JiraAPIWrapper(BaseModel):
|
|||||||
values["jira_username"] = jira_username
|
values["jira_username"] = jira_username
|
||||||
|
|
||||||
jira_api_token = get_from_dict_or_env(
|
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
|
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(
|
jira_instance_url = get_from_dict_or_env(
|
||||||
values, "jira_instance_url", "JIRA_INSTANCE_URL"
|
values, "jira_instance_url", "JIRA_INSTANCE_URL"
|
||||||
)
|
)
|
||||||
@ -47,6 +99,12 @@ class JiraAPIWrapper(BaseModel):
|
|||||||
jira_cloud = jira_cloud_str.lower() == "true"
|
jira_cloud = jira_cloud_str.lower() == "true"
|
||||||
values["jira_cloud"] = jira_cloud
|
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:
|
try:
|
||||||
from atlassian import Confluence, Jira
|
from atlassian import Confluence, Jira
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@ -55,6 +113,7 @@ class JiraAPIWrapper(BaseModel):
|
|||||||
"Please install it with `pip install atlassian-python-api`"
|
"Please install it with `pip install atlassian-python-api`"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if jira_api_token:
|
||||||
if jira_username == "":
|
if jira_username == "":
|
||||||
jira = Jira(
|
jira = Jira(
|
||||||
url=jira_instance_url,
|
url=jira_instance_url,
|
||||||
@ -75,6 +134,17 @@ class JiraAPIWrapper(BaseModel):
|
|||||||
password=jira_api_token,
|
password=jira_api_token,
|
||||||
cloud=jira_cloud,
|
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["jira"] = jira
|
||||||
values["confluence"] = confluence
|
values["confluence"] = confluence
|
||||||
@ -97,7 +167,7 @@ class JiraAPIWrapper(BaseModel):
|
|||||||
except Exception:
|
except Exception:
|
||||||
assignee = "None"
|
assignee = "None"
|
||||||
rel_issues = {}
|
rel_issues = {}
|
||||||
for related_issue in issue["fields"]["issuelinks"]:
|
for related_issue in issue["fields"].get("issuelinks", []):
|
||||||
if "inwardIssue" in related_issue.keys():
|
if "inwardIssue" in related_issue.keys():
|
||||||
rel_type = related_issue["type"]["inward"]
|
rel_type = related_issue["type"]["inward"]
|
||||||
rel_key = related_issue["inwardIssue"]["key"]
|
rel_key = related_issue["inwardIssue"]["key"]
|
||||||
@ -126,8 +196,8 @@ class JiraAPIWrapper(BaseModel):
|
|||||||
id = project["id"]
|
id = project["id"]
|
||||||
key = project["key"]
|
key = project["key"]
|
||||||
name = project["name"]
|
name = project["name"]
|
||||||
type = project["projectTypeKey"]
|
type = project.get("projectTypeKey")
|
||||||
style = project.get("style", None)
|
style = project.get("style")
|
||||||
parsed.append(
|
parsed.append(
|
||||||
{"id": id, "key": key, "name": name, "type": type, "style": style}
|
{"id": id, "key": key, "name": name, "type": type, "style": style}
|
||||||
)
|
)
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@ -60,3 +61,24 @@ class TestJiraAPIWrapper:
|
|||||||
password="test_token",
|
password="test_token",
|
||||||
cloud=False,
|
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,
|
||||||
|
)
|
||||||
|
Loading…
Reference in New Issue
Block a user