Skip to content

Feat: add oauth2 support for Jira toolkit #30684

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 40 additions & 11 deletions docs/docs/integrations/tools/jira.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -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\""
]
},
{
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"
]
},
{
Expand Down
2 changes: 1 addition & 1 deletion libs/community/langchain_community/tools/jira/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

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

Expand Down
108 changes: 89 additions & 19 deletions libs/community/langchain_community/utilities/jira.py
Original file line number Diff line number Diff line change
@@ -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):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typing_extensions.TypedDict might be needed w/ pydantic and python < 3.12

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

Expand All @@ -31,10 +63,30 @@ def validate_environment(cls, values: Dict) -> Any:
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"
)
Expand All @@ -47,6 +99,12 @@ def validate_environment(cls, values: Dict) -> Any:
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:
Expand All @@ -55,26 +113,38 @@ def validate_environment(cls, values: Dict) -> Any:
"Please install it with `pip install atlassian-python-api`"
)

if jira_username == "":
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,
token=jira_api_token,
username=jira_username,
password=jira_api_token,
cloud=jira_cloud,
)
else:
elif jira_oauth2:
jira = Jira(
url=jira_instance_url,
username=jira_username,
password=jira_api_token,
oauth2=jira_oauth2,
cloud=jira_cloud,
)
confluence = Confluence(
url=jira_instance_url,
oauth2=jira_oauth2,
cloud=jira_cloud,
)

confluence = Confluence(
url=jira_instance_url,
username=jira_username,
password=jira_api_token,
cloud=jira_cloud,
)

values["jira"] = jira
values["confluence"] = confluence
Expand All @@ -97,7 +167,7 @@ def parse_issues(self, issues: Dict) -> List[dict]:
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"]
Expand Down Expand Up @@ -126,8 +196,8 @@ def parse_projects(self, projects: List[dict]) -> List[dict]:
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}
)
Expand Down
22 changes: 22 additions & 0 deletions libs/community/tests/unit_tests/jira/test_jira_api_wrapper.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
from unittest.mock import MagicMock, patch

import pytest
Expand Down Expand Up @@ -60,3 +61,24 @@ def test_jira_api_wrapper_with_cloud_false(self, mock_jira: MagicMock) -> None:
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,
)
Loading