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:
Pol de Font-Réaulx 2025-04-11 04:04:09 +02:00 committed by GitHub
parent b6fe7e8c10
commit 48cf7c838d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 154 additions and 33 deletions

View File

@ -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"
]
},
{

View File

@ -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.
"""

View File

@ -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}
)

View File

@ -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,
)