diff --git a/jira/.env-template b/jira/.env-template index a8583e50a..82d1f731b 100644 --- a/jira/.env-template +++ b/jira/.env-template @@ -1,9 +1,12 @@ # Authentication -JIRA_USER_EMAIL=user@example.com +JIRA_AUTH_TYPE= +# OAuth +JIRA_OAUTH_CLIENT_ID= +# Basic Auth +JIRA_USER_EMAIL= JIRA_API_TOKEN= -JIRA_ORG_DOMAIN=https://space.example.com - +JIRA_ORG_DOMAIN= # Optional -JIRA_ISSUE_KEY=EX +JIRA_ISSUE_KEY=BBQ JIRA_SEARCH_LIMIT=10 -JIRA_CONNECTOR_API_KEY= +JIRA_CONNECTOR_API_KEY= \ No newline at end of file diff --git a/jira/README.md b/jira/README.md index 59aa90e62..28734fa8b 100644 --- a/jira/README.md +++ b/jira/README.md @@ -8,18 +8,71 @@ The Jira connector will perform a full-text search based on the subject and desc ## Configuration -This connector requires the following environment variables to enable Jira's search API. +The Jira connector provides two authentication +methods: [Basic](https://developer.atlassian.com/cloud/jira/platform/basic-auth-for-rest-apis/) +and [OAuth 2.0 (3LO)](https://developer.atlassian.com/cloud/jira/platform/oauth-2-3lo-apps/) + +To use Basic auth flow this connector requires the following environment variables: ``` +JIRA_AUTH_TYPE=basic JIRA_USER_EMAIL JIRA_API_TOKEN JIRA_ORG_DOMAIN ``` -It uses Basic Auth with a user email and API token pair. To create an API token, click on your top-right profile icon, select Manage Account > Security > Create API Token. The org domain is the URL for your organization's Jira board, including the https:// scheme. You will need to put these environment variables in a `.env` file for development, see `.env-template`. +It uses Basic Auth with a user email and API token pair. To create an API token, click on your top-right profile icon, select Manage Account > Security > Create API Token. +The org domain is the URL for your organization's Jira board, including the https:// scheme. +You will need to put these environment variables in a `.env` file for development, see `.env-template`. In order to use the `dev/load_data.py` script to load test tickets, the additional env var `JIRA_ISSUE_KEY` should be set. The key is typically a two-letter sequence that forms part of the issue number. +To use OAuth 2.0 (3LO) auth flow this connector requires the following environment variables: +``` +JIRA_OAUTH_CLIENT_ID +``` +You need to register your app in Jira and get the client ID. See [here](https://developer.atlassian.com/cloud/jira/platform/oauth-2-3lo-apps/) for more details. +You need to set the Jira API Permissions to `View Jira issue data(read:jira-work)` on the App Permissions page. +Also, you need to set the Authorization callback URL to `https://api.cohere.com/v1/connectors/oauth/token` on the App Authorization page. + +Next, register the connector with Cohere's API using the following configuration. +Please note that here we need to add `offline_access` to the scope, +so that we can get a refresh token and use it to get a new access token when the current one expires. + +```bash + curl -X POST \ + 'https://api.cohere.ai/v1/connectors' \ + --header 'Accept: */*' \ + --header 'Authorization: Bearer {COHERE-API-KEY}' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "name": "Jira with OAuth", + "url": "{YOUR_CONNECTOR-URL}", + "oauth": { + "client_id": "{Your Jira CLIENT-ID}", + "client_secret": "{Your Jira App SECRET}", + "authorize_url": "https://auth.atlassian.com/authorize", + "token_url": "https://auth.atlassian.com/oauth/token", + "scope": "read:jira-work offline_access" + } + }' +``` + +Once properly registered, whenever a search request is made Cohere will take care of authorizing the current user and passing the correct access tokens in the request headers. + + + +## Optional Configuration + +``` +JIRA_SEARCH_LIMIT +JIRA_CONNECTOR_API_KEY +``` + +The `JIRA_SEARCH_LIMIT` variable may contain the maximum number of results to return for a search. If this variable is not set, the default is 10. +The `JIRA_CONNECTOR_API_KEY` variable may contain the API key for the Cohere connector. Don't set this variable if you are using OAuth 2.0 (3LO) auth flow. + + ## Development Create a virtual environment and install dependencies with poetry. We recommend using in-project virtual environments: diff --git a/jira/dev/load_data.py b/jira/dev/load_data.py index 2ba7e496a..6c31be458 100644 --- a/jira/dev/load_data.py +++ b/jira/dev/load_data.py @@ -9,9 +9,9 @@ # Set your Jira details here jira = Jira( - url=os.environ.get("JIRA_PRODUCT_URL"), - username=os.environ.get("JIRA_CLIENT_USER"), - password=os.environ.get("JIRA_CLIENT_PASS"), + url=os.environ.get("JIRA_ORG_DOMAIN"), + username=os.environ.get("JIRA_USER_EMAIL"), + password=os.environ.get("JIRA_API_TOKEN"), ) project_key = os.environ.get("JIRA_ISSUE_KEY") diff --git a/jira/poetry.lock b/jira/poetry.lock index 7594d257a..3a3b46331 100644 --- a/jira/poetry.lock +++ b/jira/poetry.lock @@ -373,16 +373,6 @@ files = [ {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, @@ -811,4 +801,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "326f8ac83343d5e54a8593c5e72efd3b8647e64d92d021e7b058c7dd2abbbe11" +content-hash = "dc80d6467a3875cda2430d579ff40e13b3c6cba04b6714e5b306774b9140b86a" diff --git a/jira/provider/client.py b/jira/provider/client.py index 3faad7e13..4f6f3412a 100644 --- a/jira/provider/client.py +++ b/jira/provider/client.py @@ -1,21 +1,114 @@ from atlassian import Jira -from flask import current_app as app +import requests +from flask import current_app as app, request from . import UpstreamProviderError -client = None +AUTHORIZATION_HEADER = "Authorization" +BEARER_PREFIX = "Bearer " -def get_client(): - global client +class JiraClient: + JIRA_API_URL = "https://api.atlassian.com/ex/jira/" + JIRA_RESOURCE_URL = "https://api.atlassian.com/oauth/token/accessible-resources" + DEFAULT_SEARCH_LIMIT = 10 + + def __init__(self): + self.client = None + self.limit = self.DEFAULT_SEARCH_LIMIT + + def set_limit(self, limit): + self.limit = limit + + def search(self, query): + issues = self.client.jql( + 'text ~ "' + query + '"', + limit=self.limit, + )["issues"] + + return issues + + def construct_api_request_url(self, token): + headers = {"Authorization": f"Bearer {token}"} - if not client: + response = requests.get(self.JIRA_RESOURCE_URL, headers=headers) + if response.status_code != 200: + raise UpstreamProviderError( + f"Error while fetching Jira API URL: {response.status_code} {response.reason}" + ) + data = response.json() + if not data: + raise UpstreamProviderError( + f"Error while fetching Jira API URL: no data found" + ) + resources = data[0] + cloud_id = resources["id"] if "id" in resources else None + return f"{self.JIRA_API_URL}{cloud_id}" + + def setup_oauth_client(self, client_id, token): + if not token: + raise UpstreamProviderError( + f"Error while initializing Jira client: no access token found" + ) try: - client = Jira( - url=app.config["ORG_DOMAIN"], - username=app.config["USER_EMAIL"], - password=app.config["API_TOKEN"], + url = self.construct_api_request_url(token) + self.client = Jira( + url=url, + oauth2={ + "client_id": client_id, + "token": {"access_token": token, "token_type": "Bearer"}, + }, ) except Exception as e: - raise UpstreamProviderError(f"Error initializing Jira client: {str(e)}") + raise UpstreamProviderError( + f"Error while initializing Jira client: {str(e)}" + ) + + return self.client + + def setup_basic_client(self, user_email, org_domain, api_token): + try: + self.client = Jira( + url=org_domain, + username=user_email, + password=api_token, + ) + except Exception as e: + raise UpstreamProviderError( + f"Error while initializing Jira client: {str(e)}" + ) + + return self.client + + +def get_client(): + assert (auth_type := app.config.get("AUTH_TYPE")), "JIRA_AUTH_TYPE must be set" + jira_client = JiraClient() + jira_client.set_limit( + app.config.get("SEARCH_LIMIT", JiraClient.DEFAULT_SEARCH_LIMIT) + ) + if auth_type == "basic": + assert ( + user_email := app.config.get("USER_EMAIL") + ), "JIRA_USER_EMAIL must be set" + assert ( + org_domain := app.config.get("ORG_DOMAIN") + ), "JIRA_ORG_DOMAIN must be set" + assert (api_token := app.config.get("API_TOKEN")), "JIRA_API_TOKEN must be set" + jira_client.setup_basic_client(user_email, org_domain, api_token) + elif auth_type == "oauth": + assert ( + client_id := app.config.get("OAUTH_CLIENT_ID") + ), "JIRA_OAUTH_CLIENT_ID must be set" + token = get_access_token() + jira_client.setup_oauth_client(client_id, token) + else: + raise UpstreamProviderError(f"Invalid auth type: {auth_type}") + + return jira_client + - return client +def get_access_token(): + authorization_header = request.headers.get(AUTHORIZATION_HEADER, "") + if authorization_header.startswith(BEARER_PREFIX): + return authorization_header.removeprefix(BEARER_PREFIX) + return None diff --git a/jira/provider/provider.py b/jira/provider/provider.py index c205fdb6b..7797bbaa6 100644 --- a/jira/provider/provider.py +++ b/jira/provider/provider.py @@ -6,8 +6,6 @@ logger = logging.getLogger(__name__) -DEFAULT_SEARCH_LIMIT = 10 - def serialize_result(issue): data = {} @@ -33,10 +31,7 @@ def serialize_result(issue): def search(query): client = get_client() - issues = client.jql( - 'text ~ "' + query + '"', - limit=app.config.get("SEARCH_LIMIT", DEFAULT_SEARCH_LIMIT), - )["issues"] + issues = client.search(query) results = [] for issue in issues: diff --git a/jira/pyproject.toml b/jira/pyproject.toml index bb1d19e4d..fac558f80 100644 --- a/jira/pyproject.toml +++ b/jira/pyproject.toml @@ -12,6 +12,7 @@ flask = "2.2.5" connexion = {extras = ["swagger-ui"], version = "^2.14.2"} python-dotenv = "^1.0.0" gunicorn = "^21.2.0" +requests = "^2.31.0" [build-system]