update auth type

This commit is contained in:
CREDO23 2025-07-24 22:45:47 +02:00
parent 7af65a5a66
commit 4984aab3f1
6 changed files with 245 additions and 140 deletions

View file

@ -5,8 +5,10 @@ A module for retrieving data from Jira.
Allows fetching issue lists and their comments, projects and more. Allows fetching issue lists and their comments, projects and more.
""" """
from typing import Any, Dict, List, Optional, Tuple import base64
import json
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Optional
import requests import requests
@ -17,55 +19,76 @@ class JiraConnector:
def __init__( def __init__(
self, self,
base_url: Optional[str] = None, base_url: Optional[str] = None,
personal_access_token: Optional[str] = None, email: Optional[str] = None,
api_token: Optional[str] = None,
): ):
""" """
Initialize the JiraConnector class. Initialize the JiraConnector class.
Args: Args:
base_url: Jira instance base URL (e.g., 'https://yourcompany.atlassian.net') (optional) base_url: Jira instance base URL (e.g., 'https://yourcompany.atlassian.net') (optional)
personal_access_token: Jira personal access token (optional) email: Jira account email address (optional)
api_token: Jira API token (optional)
""" """
self.base_url = base_url.rstrip("/") if base_url else None self.base_url = base_url.rstrip("/") if base_url else None
self.personal_access_token = personal_access_token self.email = email
self.api_token = api_token
self.api_version = "3" # Jira Cloud API version self.api_version = "3" # Jira Cloud API version
def set_credentials(self, base_url: str, personal_access_token: str) -> None: def set_credentials(self, base_url: str, email: str, api_token: str) -> None:
""" """
Set the Jira credentials. Set the Jira credentials.
Args: Args:
base_url: Jira instance base URL base_url: Jira instance base URL
personal_access_token: Jira personal access token email: Jira account email address
api_token: Jira API token
""" """
self.base_url = base_url.rstrip("/") self.base_url = base_url.rstrip("/")
self.personal_access_token = personal_access_token self.email = email
self.api_token = api_token
def set_personal_access_token(self, personal_access_token: str) -> None: def set_email(self, email: str) -> None:
""" """
Set the Jira personal access token. Set the Jira account email.
Args: Args:
personal_access_token: Jira personal access token email: Jira account email address
""" """
self.personal_access_token = personal_access_token self.email = email
def set_api_token(self, api_token: str) -> None:
"""
Set the Jira API token.
Args:
api_token: Jira API token
"""
self.api_token = api_token
def get_headers(self) -> Dict[str, str]: def get_headers(self) -> Dict[str, str]:
""" """
Get headers for Jira API requests. Get headers for Jira API requests using Basic Authentication.
Returns: Returns:
Dictionary of headers Dictionary of headers
Raises: Raises:
ValueError: If personal_access_token or base_url have not been set ValueError: If email, api_token, or base_url have not been set
""" """
if not all([self.base_url, self.personal_access_token]): if not all([self.base_url, self.email, self.api_token]):
raise ValueError("Jira personal access token or base URL not initialized.") raise ValueError(
"Jira credentials not initialized. Call set_credentials() first."
)
# Create Basic Auth header using email:api_token
auth_str = f"{self.email}:{self.api_token}"
auth_bytes = auth_str.encode("utf-8")
auth_header = "Basic " + base64.b64encode(auth_bytes).decode("ascii")
return { return {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": f"Bearer {self.personal_access_token}", "Authorization": auth_header,
"Accept": "application/json", "Accept": "application/json",
} }
@ -83,17 +106,21 @@ class JiraConnector:
Response data from the API Response data from the API
Raises: Raises:
ValueError: If personal_access_token or base_url have not been set ValueError: If email, api_token, or base_url have not been set
Exception: If the API request fails Exception: If the API request fails
""" """
if not all([self.base_url, self.personal_access_token]): if not all([self.base_url, self.email, self.api_token]):
raise ValueError("Jira personal access token or base URL not initialized.") raise ValueError(
"Jira credentials not initialized. Call set_credentials() first."
)
url = f"{self.base_url}/rest/api/{self.api_version}/{endpoint}" url = f"{self.base_url}/rest/api/{self.api_version}/{endpoint}"
headers = self.get_headers() headers = self.get_headers()
response = requests.get(url, headers=headers, params=params, timeout=500) response = requests.get(url, headers=headers, params=params, timeout=500)
print(json.dumps(response.json(), indent=2))
if response.status_code == 200: if response.status_code == 200:
return response.json() return response.json()
else: else:
@ -197,9 +224,11 @@ class JiraConnector:
try: try:
# Build JQL query for date range # Build JQL query for date range
# Query issues that were either created OR updated within the date range # Query issues that were either created OR updated within the date range
date_filter = f"(created >= '{start_date}' AND created <= '{end_date}') OR (updated >= '{start_date}' AND updated <= '{end_date}')" date_filter = (
f"(createdDate >= '{start_date}' AND createdDate <= '{end_date}')"
)
jql = f"{date_filter} ORDER BY created DESC" jql = f"{date_filter}"
if project_key: if project_key:
jql = ( jql = (
f'project = "{project_key}" AND {date_filter} ORDER BY created DESC' f'project = "{project_key}" AND {date_filter} ORDER BY created DESC'
@ -234,8 +263,11 @@ class JiraConnector:
while True: while True:
params["startAt"] = start_at params["startAt"] = start_at
print(json.dumps(params, indent=2))
result = self.make_api_request("search", params) result = self.make_api_request("search", params)
print(json.dumps(result, indent=2))
if not isinstance(result, dict) or "issues" not in result: if not isinstance(result, dict) or "issues" not in result:
return [], "Invalid response from Jira API" return [], "Invalid response from Jira API"

View file

@ -1,104 +1,112 @@
import unittest import unittest
from unittest.mock import patch, Mock from unittest.mock import Mock, patch
from datetime import datetime
# Import the JiraConnector # Import the JiraConnector
from .jira_connector import JiraConnector from .jira_connector import JiraConnector
class TestJiraConnector(unittest.TestCase): class TestJiraConnector(unittest.TestCase):
def setUp(self): def setUp(self):
"""Set up test fixtures.""" """Set up test fixtures."""
self.base_url = "https://test.atlassian.net" self.base_url = "https://test.atlassian.net"
self.token = "test_token" self.email = "test@example.com"
self.connector = JiraConnector(base_url=self.base_url, personal_access_token=self.token) self.api_token = "test_api_token"
self.connector = JiraConnector(
base_url=self.base_url, email=self.email, api_token=self.api_token
)
def test_init(self): def test_init(self):
"""Test JiraConnector initialization.""" """Test JiraConnector initialization."""
self.assertEqual(self.connector.base_url, self.base_url) self.assertEqual(self.connector.base_url, self.base_url)
self.assertEqual(self.connector.personal_access_token, self.token) self.assertEqual(self.connector.email, self.email)
self.assertEqual(self.connector.api_token, self.api_token)
self.assertEqual(self.connector.api_version, "3") self.assertEqual(self.connector.api_version, "3")
def test_init_with_trailing_slash(self): def test_init_with_trailing_slash(self):
"""Test JiraConnector initialization with trailing slash in URL.""" """Test JiraConnector initialization with trailing slash in URL."""
connector = JiraConnector(base_url="https://test.atlassian.net/", personal_access_token=self.token) connector = JiraConnector(
base_url="https://test.atlassian.net/",
email=self.email,
api_token=self.api_token,
)
self.assertEqual(connector.base_url, "https://test.atlassian.net") self.assertEqual(connector.base_url, "https://test.atlassian.net")
def test_set_credentials(self): def test_set_credentials(self):
"""Test setting credentials.""" """Test setting credentials."""
new_url = "https://newtest.atlassian.net/" new_url = "https://newtest.atlassian.net/"
new_token = "new_token" new_email = "new@example.com"
new_token = "new_api_token"
self.connector.set_credentials(new_url, new_token)
self.connector.set_credentials(new_url, new_email, new_token)
self.assertEqual(self.connector.base_url, "https://newtest.atlassian.net") self.assertEqual(self.connector.base_url, "https://newtest.atlassian.net")
self.assertEqual(self.connector.personal_access_token, new_token) self.assertEqual(self.connector.email, new_email)
self.assertEqual(self.connector.api_token, new_token)
def test_get_headers(self): def test_get_headers(self):
"""Test header generation.""" """Test header generation."""
headers = self.connector.get_headers() headers = self.connector.get_headers()
self.assertIn('Content-Type', headers) self.assertIn("Content-Type", headers)
self.assertIn('Authorization', headers) self.assertIn("Authorization", headers)
self.assertIn('Accept', headers) self.assertIn("Accept", headers)
self.assertEqual(headers['Content-Type'], 'application/json') self.assertEqual(headers["Content-Type"], "application/json")
self.assertEqual(headers['Accept'], 'application/json') self.assertEqual(headers["Accept"], "application/json")
self.assertTrue(headers['Authorization'].startswith('Bearer ')) self.assertTrue(headers["Authorization"].startswith("Basic "))
def test_get_headers_no_credentials(self): def test_get_headers_no_credentials(self):
"""Test header generation without credentials.""" """Test header generation without credentials."""
connector = JiraConnector() connector = JiraConnector()
with self.assertRaises(ValueError) as context: with self.assertRaises(ValueError) as context:
connector.get_headers() connector.get_headers()
self.assertIn("Jira credentials not initialized", str(context.exception)) self.assertIn("Jira credentials not initialized", str(context.exception))
@patch('requests.get') @patch("requests.get")
def test_make_api_request_success(self, mock_get): def test_make_api_request_success(self, mock_get):
"""Test successful API request.""" """Test successful API request."""
mock_response = Mock() mock_response = Mock()
mock_response.status_code = 200 mock_response.status_code = 200
mock_response.json.return_value = {"test": "data"} mock_response.json.return_value = {"test": "data"}
mock_get.return_value = mock_response mock_get.return_value = mock_response
result = self.connector.make_api_request("test/endpoint") result = self.connector.make_api_request("test/endpoint")
self.assertEqual(result, {"test": "data"}) self.assertEqual(result, {"test": "data"})
mock_get.assert_called_once() mock_get.assert_called_once()
@patch('requests.get') @patch("requests.get")
def test_make_api_request_failure(self, mock_get): def test_make_api_request_failure(self, mock_get):
"""Test failed API request.""" """Test failed API request."""
mock_response = Mock() mock_response = Mock()
mock_response.status_code = 401 mock_response.status_code = 401
mock_response.text = "Unauthorized" mock_response.text = "Unauthorized"
mock_get.return_value = mock_response mock_get.return_value = mock_response
with self.assertRaises(Exception) as context: with self.assertRaises(Exception) as context:
self.connector.make_api_request("test/endpoint") self.connector.make_api_request("test/endpoint")
self.assertIn("API request failed with status code 401", str(context.exception)) self.assertIn("API request failed with status code 401", str(context.exception))
@patch.object(JiraConnector, 'make_api_request') @patch.object(JiraConnector, "make_api_request")
def test_get_all_projects(self, mock_api_request): def test_get_all_projects(self, mock_api_request):
"""Test getting all projects.""" """Test getting all projects."""
mock_api_request.return_value = { mock_api_request.return_value = {
"values": [ "values": [
{"id": "1", "key": "TEST", "name": "Test Project"}, {"id": "1", "key": "TEST", "name": "Test Project"},
{"id": "2", "key": "DEMO", "name": "Demo Project"} {"id": "2", "key": "DEMO", "name": "Demo Project"},
] ]
} }
projects = self.connector.get_all_projects() projects = self.connector.get_all_projects()
self.assertEqual(len(projects), 2) self.assertEqual(len(projects), 2)
self.assertEqual(projects[0]["key"], "TEST") self.assertEqual(projects[0]["key"], "TEST")
self.assertEqual(projects[1]["key"], "DEMO") self.assertEqual(projects[1]["key"], "DEMO")
mock_api_request.assert_called_once_with("project") mock_api_request.assert_called_once_with("project")
@patch.object(JiraConnector, 'make_api_request') @patch.object(JiraConnector, "make_api_request")
def test_get_all_issues(self, mock_api_request): def test_get_all_issues(self, mock_api_request):
"""Test getting all issues.""" """Test getting all issues."""
mock_api_request.return_value = { mock_api_request.return_value = {
@ -114,15 +122,15 @@ class TestJiraConnector(unittest.TestCase):
"issuetype": {"name": "Bug"}, "issuetype": {"name": "Bug"},
"project": {"key": "TEST"}, "project": {"key": "TEST"},
"created": "2023-01-01T10:00:00.000+0000", "created": "2023-01-01T10:00:00.000+0000",
"updated": "2023-01-01T12:00:00.000+0000" "updated": "2023-01-01T12:00:00.000+0000",
} },
} }
], ],
"total": 1 "total": 1,
} }
issues = self.connector.get_all_issues() issues = self.connector.get_all_issues()
self.assertEqual(len(issues), 1) self.assertEqual(len(issues), 1)
self.assertEqual(issues[0]["key"], "TEST-1") self.assertEqual(issues[0]["key"], "TEST-1")
self.assertEqual(issues[0]["fields"]["summary"], "Test Issue") self.assertEqual(issues[0]["fields"]["summary"], "Test Issue")
@ -144,18 +152,18 @@ class TestJiraConnector(unittest.TestCase):
"reporter": { "reporter": {
"accountId": "123", "accountId": "123",
"displayName": "John Doe", "displayName": "John Doe",
"emailAddress": "john@example.com" "emailAddress": "john@example.com",
}, },
"assignee": { "assignee": {
"accountId": "456", "accountId": "456",
"displayName": "Jane Smith", "displayName": "Jane Smith",
"emailAddress": "jane@example.com" "emailAddress": "jane@example.com",
} },
} },
} }
formatted = self.connector.format_issue(raw_issue) formatted = self.connector.format_issue(raw_issue)
self.assertEqual(formatted["id"], "1") self.assertEqual(formatted["id"], "1")
self.assertEqual(formatted["key"], "TEST-1") self.assertEqual(formatted["key"], "TEST-1")
self.assertEqual(formatted["title"], "Test Issue") self.assertEqual(formatted["title"], "Test Issue")
@ -170,17 +178,17 @@ class TestJiraConnector(unittest.TestCase):
"""Test date formatting.""" """Test date formatting."""
iso_date = "2023-01-01T10:30:00.000+0000" iso_date = "2023-01-01T10:30:00.000+0000"
formatted_date = JiraConnector.format_date(iso_date) formatted_date = JiraConnector.format_date(iso_date)
self.assertEqual(formatted_date, "2023-01-01 10:30:00") self.assertEqual(formatted_date, "2023-01-01 10:30:00")
def test_format_date_invalid(self): def test_format_date_invalid(self):
"""Test date formatting with invalid input.""" """Test date formatting with invalid input."""
formatted_date = JiraConnector.format_date("invalid-date") formatted_date = JiraConnector.format_date("invalid-date")
self.assertEqual(formatted_date, "invalid-date") self.assertEqual(formatted_date, "invalid-date")
formatted_date = JiraConnector.format_date("") formatted_date = JiraConnector.format_date("")
self.assertEqual(formatted_date, "Unknown date") self.assertEqual(formatted_date, "Unknown date")
formatted_date = JiraConnector.format_date(None) formatted_date = JiraConnector.format_date(None)
self.assertEqual(formatted_date, "Unknown date") self.assertEqual(formatted_date, "Unknown date")
@ -198,11 +206,11 @@ class TestJiraConnector(unittest.TestCase):
"created_at": "2023-01-01T10:00:00.000+0000", "created_at": "2023-01-01T10:00:00.000+0000",
"updated_at": "2023-01-01T12:00:00.000+0000", "updated_at": "2023-01-01T12:00:00.000+0000",
"description": "Test Description", "description": "Test Description",
"comments": [] "comments": [],
} }
markdown = self.connector.format_issue_to_markdown(formatted_issue) markdown = self.connector.format_issue_to_markdown(formatted_issue)
self.assertIn("# TEST-1: Test Issue", markdown) self.assertIn("# TEST-1: Test Issue", markdown)
self.assertIn("**Status:** Open", markdown) self.assertIn("**Status:** Open", markdown)
self.assertIn("**Priority:** High", markdown) self.assertIn("**Priority:** High", markdown)
@ -214,5 +222,5 @@ class TestJiraConnector(unittest.TestCase):
self.assertIn("Test Description", markdown) self.assertIn("Test Description", markdown)
if __name__ == '__main__': if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -1,9 +1,12 @@
from datetime import datetime
import uuid import uuid
from typing import Dict, Any, Optional from datetime import datetime
from pydantic import BaseModel, field_validator, ConfigDict from typing import Any, Dict, Optional
from .base import IDModel, TimestampModel
from app.db import SearchSourceConnectorType from app.db import SearchSourceConnectorType
from pydantic import BaseModel, ConfigDict, field_validator
from .base import IDModel, TimestampModel
class SearchSourceConnectorBase(BaseModel): class SearchSourceConnectorBase(BaseModel):
name: str name: str
@ -11,105 +14,129 @@ class SearchSourceConnectorBase(BaseModel):
is_indexable: bool is_indexable: bool
last_indexed_at: Optional[datetime] = None last_indexed_at: Optional[datetime] = None
config: Dict[str, Any] config: Dict[str, Any]
@field_validator('config') @field_validator("config")
@classmethod @classmethod
def validate_config_for_connector_type(cls, config: Dict[str, Any], values: Dict[str, Any]) -> Dict[str, Any]: def validate_config_for_connector_type(
connector_type = values.data.get('connector_type') cls, config: Dict[str, Any], values: Dict[str, Any]
) -> Dict[str, Any]:
connector_type = values.data.get("connector_type")
if connector_type == SearchSourceConnectorType.SERPER_API: if connector_type == SearchSourceConnectorType.SERPER_API:
# For SERPER_API, only allow SERPER_API_KEY # For SERPER_API, only allow SERPER_API_KEY
allowed_keys = ["SERPER_API_KEY"] allowed_keys = ["SERPER_API_KEY"]
if set(config.keys()) != set(allowed_keys): if set(config.keys()) != set(allowed_keys):
raise ValueError(f"For SERPER_API connector type, config must only contain these keys: {allowed_keys}") raise ValueError(
f"For SERPER_API connector type, config must only contain these keys: {allowed_keys}"
)
# Ensure the API key is not empty # Ensure the API key is not empty
if not config.get("SERPER_API_KEY"): if not config.get("SERPER_API_KEY"):
raise ValueError("SERPER_API_KEY cannot be empty") raise ValueError("SERPER_API_KEY cannot be empty")
elif connector_type == SearchSourceConnectorType.TAVILY_API: elif connector_type == SearchSourceConnectorType.TAVILY_API:
# For TAVILY_API, only allow TAVILY_API_KEY # For TAVILY_API, only allow TAVILY_API_KEY
allowed_keys = ["TAVILY_API_KEY"] allowed_keys = ["TAVILY_API_KEY"]
if set(config.keys()) != set(allowed_keys): if set(config.keys()) != set(allowed_keys):
raise ValueError(f"For TAVILY_API connector type, config must only contain these keys: {allowed_keys}") raise ValueError(
f"For TAVILY_API connector type, config must only contain these keys: {allowed_keys}"
)
# Ensure the API key is not empty # Ensure the API key is not empty
if not config.get("TAVILY_API_KEY"): if not config.get("TAVILY_API_KEY"):
raise ValueError("TAVILY_API_KEY cannot be empty") raise ValueError("TAVILY_API_KEY cannot be empty")
elif connector_type == SearchSourceConnectorType.LINKUP_API: elif connector_type == SearchSourceConnectorType.LINKUP_API:
# For LINKUP_API, only allow LINKUP_API_KEY # For LINKUP_API, only allow LINKUP_API_KEY
allowed_keys = ["LINKUP_API_KEY"] allowed_keys = ["LINKUP_API_KEY"]
if set(config.keys()) != set(allowed_keys): if set(config.keys()) != set(allowed_keys):
raise ValueError(f"For LINKUP_API connector type, config must only contain these keys: {allowed_keys}") raise ValueError(
f"For LINKUP_API connector type, config must only contain these keys: {allowed_keys}"
)
# Ensure the API key is not empty # Ensure the API key is not empty
if not config.get("LINKUP_API_KEY"): if not config.get("LINKUP_API_KEY"):
raise ValueError("LINKUP_API_KEY cannot be empty") raise ValueError("LINKUP_API_KEY cannot be empty")
elif connector_type == SearchSourceConnectorType.SLACK_CONNECTOR: elif connector_type == SearchSourceConnectorType.SLACK_CONNECTOR:
# For SLACK_CONNECTOR, only allow SLACK_BOT_TOKEN # For SLACK_CONNECTOR, only allow SLACK_BOT_TOKEN
allowed_keys = ["SLACK_BOT_TOKEN"] allowed_keys = ["SLACK_BOT_TOKEN"]
if set(config.keys()) != set(allowed_keys): if set(config.keys()) != set(allowed_keys):
raise ValueError(f"For SLACK_CONNECTOR connector type, config must only contain these keys: {allowed_keys}") raise ValueError(
f"For SLACK_CONNECTOR connector type, config must only contain these keys: {allowed_keys}"
)
# Ensure the bot token is not empty # Ensure the bot token is not empty
if not config.get("SLACK_BOT_TOKEN"): if not config.get("SLACK_BOT_TOKEN"):
raise ValueError("SLACK_BOT_TOKEN cannot be empty") raise ValueError("SLACK_BOT_TOKEN cannot be empty")
elif connector_type == SearchSourceConnectorType.NOTION_CONNECTOR: elif connector_type == SearchSourceConnectorType.NOTION_CONNECTOR:
# For NOTION_CONNECTOR, only allow NOTION_INTEGRATION_TOKEN # For NOTION_CONNECTOR, only allow NOTION_INTEGRATION_TOKEN
allowed_keys = ["NOTION_INTEGRATION_TOKEN"] allowed_keys = ["NOTION_INTEGRATION_TOKEN"]
if set(config.keys()) != set(allowed_keys): if set(config.keys()) != set(allowed_keys):
raise ValueError(f"For NOTION_CONNECTOR connector type, config must only contain these keys: {allowed_keys}") raise ValueError(
f"For NOTION_CONNECTOR connector type, config must only contain these keys: {allowed_keys}"
)
# Ensure the integration token is not empty # Ensure the integration token is not empty
if not config.get("NOTION_INTEGRATION_TOKEN"): if not config.get("NOTION_INTEGRATION_TOKEN"):
raise ValueError("NOTION_INTEGRATION_TOKEN cannot be empty") raise ValueError("NOTION_INTEGRATION_TOKEN cannot be empty")
elif connector_type == SearchSourceConnectorType.GITHUB_CONNECTOR: elif connector_type == SearchSourceConnectorType.GITHUB_CONNECTOR:
# For GITHUB_CONNECTOR, only allow GITHUB_PAT and repo_full_names # For GITHUB_CONNECTOR, only allow GITHUB_PAT and repo_full_names
allowed_keys = ["GITHUB_PAT", "repo_full_names"] allowed_keys = ["GITHUB_PAT", "repo_full_names"]
if set(config.keys()) != set(allowed_keys): if set(config.keys()) != set(allowed_keys):
raise ValueError(f"For GITHUB_CONNECTOR connector type, config must only contain these keys: {allowed_keys}") raise ValueError(
f"For GITHUB_CONNECTOR connector type, config must only contain these keys: {allowed_keys}"
)
# Ensure the token is not empty # Ensure the token is not empty
if not config.get("GITHUB_PAT"): if not config.get("GITHUB_PAT"):
raise ValueError("GITHUB_PAT cannot be empty") raise ValueError("GITHUB_PAT cannot be empty")
# Ensure the repo_full_names is present and is a non-empty list # Ensure the repo_full_names is present and is a non-empty list
repo_full_names = config.get("repo_full_names") repo_full_names = config.get("repo_full_names")
if not isinstance(repo_full_names, list) or not repo_full_names: if not isinstance(repo_full_names, list) or not repo_full_names:
raise ValueError("repo_full_names must be a non-empty list of strings") raise ValueError("repo_full_names must be a non-empty list of strings")
elif connector_type == SearchSourceConnectorType.LINEAR_CONNECTOR: elif connector_type == SearchSourceConnectorType.LINEAR_CONNECTOR:
# For LINEAR_CONNECTOR, only allow LINEAR_API_KEY # For LINEAR_CONNECTOR, only allow LINEAR_API_KEY
allowed_keys = ["LINEAR_API_KEY"] allowed_keys = ["LINEAR_API_KEY"]
if set(config.keys()) != set(allowed_keys): if set(config.keys()) != set(allowed_keys):
raise ValueError(f"For LINEAR_CONNECTOR connector type, config must only contain these keys: {allowed_keys}") raise ValueError(
f"For LINEAR_CONNECTOR connector type, config must only contain these keys: {allowed_keys}"
)
# Ensure the token is not empty # Ensure the token is not empty
if not config.get("LINEAR_API_KEY"): if not config.get("LINEAR_API_KEY"):
raise ValueError("LINEAR_API_KEY cannot be empty") raise ValueError("LINEAR_API_KEY cannot be empty")
elif connector_type == SearchSourceConnectorType.DISCORD_CONNECTOR: elif connector_type == SearchSourceConnectorType.DISCORD_CONNECTOR:
# For DISCORD_CONNECTOR, only allow DISCORD_BOT_TOKEN # For DISCORD_CONNECTOR, only allow DISCORD_BOT_TOKEN
allowed_keys = ["DISCORD_BOT_TOKEN"] allowed_keys = ["DISCORD_BOT_TOKEN"]
if set(config.keys()) != set(allowed_keys): if set(config.keys()) != set(allowed_keys):
raise ValueError(f"For DISCORD_CONNECTOR connector type, config must only contain these keys: {allowed_keys}") raise ValueError(
f"For DISCORD_CONNECTOR connector type, config must only contain these keys: {allowed_keys}"
)
# Ensure the bot token is not empty # Ensure the bot token is not empty
if not config.get("DISCORD_BOT_TOKEN"): if not config.get("DISCORD_BOT_TOKEN"):
raise ValueError("DISCORD_BOT_TOKEN cannot be empty") raise ValueError("DISCORD_BOT_TOKEN cannot be empty")
elif connector_type == SearchSourceConnectorType.JIRA_CONNECTOR: elif connector_type == SearchSourceConnectorType.JIRA_CONNECTOR:
# For JIRA_CONNECTOR, allow JIRA_PERSONAL_ACCESS_TOKEN and JIRA_BASE_URL # For JIRA_CONNECTOR, require JIRA_EMAIL, JIRA_API_TOKEN and JIRA_BASE_URL
allowed_keys = ["JIRA_PERSONAL_ACCESS_TOKEN", "JIRA_BASE_URL"] allowed_keys = ["JIRA_EMAIL", "JIRA_API_TOKEN", "JIRA_BASE_URL"]
if set(config.keys()) != set(allowed_keys): if set(config.keys()) != set(allowed_keys):
raise ValueError(f"For JIRA_CONNECTOR connector type, config must only contain these keys: {allowed_keys}") raise ValueError(
f"For JIRA_CONNECTOR connector type, config must only contain these keys: {allowed_keys}"
)
# Ensure the token is not empty # Ensure the email is not empty
if not config.get("JIRA_PERSONAL_ACCESS_TOKEN"): if not config.get("JIRA_EMAIL"):
raise ValueError("JIRA_PERSONAL_ACCESS_TOKEN cannot be empty") raise ValueError("JIRA_EMAIL cannot be empty")
# Ensure the API token is not empty
if not config.get("JIRA_API_TOKEN"):
raise ValueError("JIRA_API_TOKEN cannot be empty")
# Ensure the base URL is not empty # Ensure the base URL is not empty
if not config.get("JIRA_BASE_URL"): if not config.get("JIRA_BASE_URL"):
@ -117,9 +144,11 @@ class SearchSourceConnectorBase(BaseModel):
return config return config
class SearchSourceConnectorCreate(SearchSourceConnectorBase): class SearchSourceConnectorCreate(SearchSourceConnectorBase):
pass pass
class SearchSourceConnectorUpdate(BaseModel): class SearchSourceConnectorUpdate(BaseModel):
name: Optional[str] = None name: Optional[str] = None
connector_type: Optional[SearchSourceConnectorType] = None connector_type: Optional[SearchSourceConnectorType] = None
@ -127,7 +156,8 @@ class SearchSourceConnectorUpdate(BaseModel):
last_indexed_at: Optional[datetime] = None last_indexed_at: Optional[datetime] = None
config: Optional[Dict[str, Any]] = None config: Optional[Dict[str, Any]] = None
class SearchSourceConnectorRead(SearchSourceConnectorBase, IDModel, TimestampModel): class SearchSourceConnectorRead(SearchSourceConnectorBase, IDModel, TimestampModel):
user_id: uuid.UUID user_id: uuid.UUID
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)

View file

@ -1,4 +1,5 @@
import asyncio import asyncio
import json
import logging import logging
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Optional, Tuple from typing import Optional, Tuple
@ -2041,10 +2042,11 @@ async def index_jira_issues(
return 0, f"Connector with ID {connector_id} not found" return 0, f"Connector with ID {connector_id} not found"
# Get the Jira credentials from the connector config # Get the Jira credentials from the connector config
jira_token = connector.config.get("JIRA_PERSONAL_ACCESS_TOKEN") jira_email = connector.config.get("JIRA_EMAIL")
jira_api_token = connector.config.get("JIRA_API_TOKEN")
jira_base_url = connector.config.get("JIRA_BASE_URL") jira_base_url = connector.config.get("JIRA_BASE_URL")
if not jira_token or not jira_base_url: if not jira_email or not jira_api_token or not jira_base_url:
await task_logger.log_task_failure( await task_logger.log_task_failure(
log_entry, log_entry,
f"Jira credentials not found in connector config for connector {connector_id}", f"Jira credentials not found in connector config for connector {connector_id}",
@ -2061,7 +2063,7 @@ async def index_jira_issues(
) )
jira_client = JiraConnector( jira_client = JiraConnector(
base_url=jira_base_url, personal_access_token=jira_token base_url=jira_base_url, email=jira_email, api_token=jira_api_token
) )
# Calculate date range # Calculate date range
@ -2097,6 +2099,8 @@ async def index_jira_issues(
start_date=start_date_str, end_date=end_date_str, include_comments=True start_date=start_date_str, end_date=end_date_str, include_comments=True
) )
print(json.dumps(issues, indent=2))
if error: if error:
logger.error(f"Failed to get Jira issues: {error}") logger.error(f"Failed to get Jira issues: {error}")
@ -2112,10 +2116,10 @@ async def index_jira_issues(
f"Updated last_indexed_at to {connector.last_indexed_at} despite no issues found" f"Updated last_indexed_at to {connector.last_indexed_at} despite no issues found"
) )
await task_logger.log_task_completion( await task_logger.log_task_success(
log_entry, log_entry,
f"No Jira issues found in date range {start_date_str} to {end_date_str}", f"No Jira issues found in date range {start_date_str} to {end_date_str}",
{"indexed_count": 0}, {"issues_found": 0},
) )
return 0, None return 0, None
else: else:
@ -2132,7 +2136,7 @@ async def index_jira_issues(
await task_logger.log_task_progress( await task_logger.log_task_progress(
log_entry, log_entry,
f"Retrieved {len(issues)} issues from Jira API", f"Retrieved {len(issues)} issues from Jira API",
{"stage": "processing_issues", "issue_count": len(issues)}, {"stage": "processing_issues", "issues_found": len(issues)},
) )
except Exception as e: except Exception as e:
@ -2254,10 +2258,10 @@ async def index_jira_issues(
await session.commit() await session.commit()
logger.info(f"Updated last_indexed_at to {connector.last_indexed_at}") logger.info(f"Updated last_indexed_at to {connector.last_indexed_at}")
await task_logger.log_task_completion( await task_logger.log_task_success(
log_entry, log_entry,
f"Successfully indexed {indexed_count} Jira issues", f"Successfully indexed {indexed_count} Jira issues",
{"indexed_count": indexed_count}, {"issues_indexed": indexed_count},
) )
logger.info(f"Successfully indexed {indexed_count} Jira issues") logger.info(f"Successfully indexed {indexed_count} Jira issues")

View file

@ -193,10 +193,17 @@ export default function EditConnectorPage() {
/> />
<EditSimpleTokenForm <EditSimpleTokenForm
control={editForm.control} control={editForm.control}
fieldName="JIRA_PERSONAL_ACCESS_TOKEN" fieldName="JIRA_EMAIL"
fieldLabel="Jira Personal Access Token" fieldLabel="Jira Email"
fieldDescription="Update your Jira Personal Access Token if needed." fieldDescription="Update your Atlassian account email if needed."
placeholder="Your Jira Personal Access Token" placeholder="your.email@company.com"
/>
<EditSimpleTokenForm
control={editForm.control}
fieldName="JIRA_API_TOKEN"
fieldLabel="Jira API Token"
fieldDescription="Update your Jira API Token if needed."
placeholder="Your Jira API Token"
/> />
</div> </div>
)} )}

View file

@ -57,8 +57,11 @@ const jiraConnectorFormSchema = z.object({
message: "Please enter a valid Jira instance URL", message: "Please enter a valid Jira instance URL",
}, },
), ),
personal_access_token: z.string().min(10, { email: z.string().email({
message: "Jira Personal Access Token is required and must be valid.", message: "Please enter a valid email address.",
}),
api_token: z.string().min(10, {
message: "Jira API Token is required and must be valid.",
}), }),
}); });
@ -78,7 +81,8 @@ export default function JiraConnectorPage() {
defaultValues: { defaultValues: {
name: "Jira Connector", name: "Jira Connector",
base_url: "", base_url: "",
personal_access_token: "", email: "",
api_token: "",
}, },
}); });
@ -91,7 +95,8 @@ export default function JiraConnectorPage() {
connector_type: "JIRA_CONNECTOR", connector_type: "JIRA_CONNECTOR",
config: { config: {
JIRA_BASE_URL: values.base_url, JIRA_BASE_URL: values.base_url,
JIRA_PERSONAL_ACCESS_TOKEN: values.personal_access_token, JIRA_EMAIL: values.email,
JIRA_API_TOKEN: values.api_token,
}, },
is_indexable: true, is_indexable: true,
last_indexed_at: null, last_indexed_at: null,
@ -210,20 +215,40 @@ export default function JiraConnectorPage() {
<FormField <FormField
control={form.control} control={form.control}
name="personal_access_token" name="email"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Personal Access Token</FormLabel> <FormLabel>Email Address</FormLabel>
<FormControl> <FormControl>
<Input <Input
type="password" type="email"
placeholder="Your Jira Personal Access Token" placeholder="your.email@company.com"
{...field} {...field}
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Your Jira Personal Access Token will be encrypted Your Atlassian account email address.
and stored securely. </FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_token"
render={({ field }) => (
<FormItem>
<FormLabel>API Token</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Your Jira API Token"
{...field}
/>
</FormControl>
<FormDescription>
Your Jira API Token will be encrypted and stored securely.
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -296,8 +321,8 @@ export default function JiraConnectorPage() {
<div> <div>
<h3 className="text-xl font-semibold mb-2">How it works</h3> <h3 className="text-xl font-semibold mb-2">How it works</h3>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
The Jira connector uses the Jira REST API to fetch all The Jira connector uses the Jira REST API with Basic Authentication
issues and comments that the Personal Access Token has to fetch all issues and comments that your account has
access to within your Jira instance. access to within your Jira instance.
</p> </p>
<ul className="mt-2 list-disc pl-5 text-muted-foreground"> <ul className="mt-2 list-disc pl-5 text-muted-foreground">
@ -324,15 +349,14 @@ export default function JiraConnectorPage() {
<AlertTitle>Read-Only Access is Sufficient</AlertTitle> <AlertTitle>Read-Only Access is Sufficient</AlertTitle>
<AlertDescription> <AlertDescription>
You only need read access for this connector to work. You only need read access for this connector to work.
The Personal Access Token will only be used to read The API Token will only be used to read your Jira data.
your Jira data.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h4 className="font-medium mb-2"> <h4 className="font-medium mb-2">
Step 1: Create a Personal Access Token Step 1: Create an API Token
</h4> </h4>
<ol className="list-decimal pl-5 space-y-3"> <ol className="list-decimal pl-5 space-y-3">
<li>Log in to your Atlassian account</li> <li>Log in to your Atlassian account</li>
@ -369,10 +393,10 @@ export default function JiraConnectorPage() {
Step 2: Grant necessary access Step 2: Grant necessary access
</h4> </h4>
<p className="text-muted-foreground mb-3"> <p className="text-muted-foreground mb-3">
The Personal Access Token will have access to all The API Token will have access to all projects and
projects and issues that your user account can see. issues that your user account can see. Make sure your
Make sure your account has appropriate permissions account has appropriate permissions for the projects
for the projects you want to index. you want to index.
</p> </p>
<Alert className="bg-muted"> <Alert className="bg-muted">
<Info className="h-4 w-4" /> <Info className="h-4 w-4" />