mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 13:47:19 +08:00
[Jira]: improve traceability and reliability fixes (#12515)
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
@@ -1,18 +1,37 @@
|
||||
from typing import Tuple
|
||||
from urllib.parse import urlparse
|
||||
"""Jira integration manager.
|
||||
|
||||
This module orchestrates the processing of Jira webhook events:
|
||||
1. Parse webhook payload (via JiraPayloadParser)
|
||||
2. Validate workspace
|
||||
3. Authenticate user
|
||||
4. Create view with repository selection (via JiraFactory)
|
||||
5. Start conversation job
|
||||
|
||||
The manager delegates payload parsing to JiraPayloadParser and view creation
|
||||
to JiraFactory, keeping the orchestration logic clean and traceable.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
from integrations.jira.jira_types import JiraViewInterface
|
||||
from integrations.jira.jira_view import (
|
||||
JiraFactory,
|
||||
JiraNewConversationView,
|
||||
from integrations.jira.jira_payload import (
|
||||
JiraPayloadError,
|
||||
JiraPayloadParser,
|
||||
JiraPayloadSkipped,
|
||||
JiraPayloadSuccess,
|
||||
JiraWebhookPayload,
|
||||
)
|
||||
from integrations.jira.jira_types import (
|
||||
JiraViewInterface,
|
||||
RepositoryNotFoundError,
|
||||
StartingConvoException,
|
||||
)
|
||||
from integrations.jira.jira_view import JiraFactory, JiraNewConversationView
|
||||
from integrations.manager import Manager
|
||||
from integrations.models import JobContext, Message
|
||||
from integrations.models import Message
|
||||
from integrations.utils import (
|
||||
HOST,
|
||||
HOST_URL,
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR,
|
||||
filter_potential_repos_by_user_msg,
|
||||
get_oh_labels,
|
||||
get_session_expired_message,
|
||||
)
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
@@ -24,9 +43,6 @@ from storage.jira_user import JiraUser
|
||||
from storage.jira_workspace import JiraWorkspace
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.integrations.service_types import Repository
|
||||
from openhands.server.shared import server_config
|
||||
from openhands.server.types import (
|
||||
LLMAuthenticationError,
|
||||
MissingSettingsError,
|
||||
@@ -37,267 +53,211 @@ from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
JIRA_CLOUD_API_URL = 'https://api.atlassian.com/ex/jira'
|
||||
|
||||
# Get OH labels for this environment
|
||||
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
|
||||
|
||||
|
||||
class JiraManager(Manager):
|
||||
"""Manager for processing Jira webhook events.
|
||||
|
||||
This class orchestrates the flow from webhook receipt to conversation creation,
|
||||
delegating parsing to JiraPayloadParser and view creation to JiraFactory.
|
||||
"""
|
||||
|
||||
def __init__(self, token_manager: TokenManager):
|
||||
self.token_manager = token_manager
|
||||
self.integration_store = JiraIntegrationStore.get_instance()
|
||||
self.jinja_env = Environment(
|
||||
loader=FileSystemLoader(OPENHANDS_RESOLVER_TEMPLATES_DIR + 'jira')
|
||||
)
|
||||
self.payload_parser = JiraPayloadParser(
|
||||
oh_label=OH_LABEL,
|
||||
inline_oh_label=INLINE_OH_LABEL,
|
||||
)
|
||||
|
||||
async def authenticate_user(
|
||||
self, jira_user_id: str, workspace_id: int
|
||||
async def receive_message(self, message: Message):
|
||||
"""Process incoming Jira webhook message.
|
||||
|
||||
Flow:
|
||||
1. Parse webhook payload
|
||||
2. Validate workspace exists and is active
|
||||
3. Authenticate user
|
||||
4. Create view (includes fetching issue details and selecting repository)
|
||||
5. Start job
|
||||
|
||||
Each step has clear logging for traceability.
|
||||
"""
|
||||
raw_payload = message.message.get('payload', {})
|
||||
|
||||
# Step 1: Parse webhook payload
|
||||
logger.info(
|
||||
'[Jira] Received webhook',
|
||||
extra={'raw_payload': raw_payload},
|
||||
)
|
||||
|
||||
parse_result = self.payload_parser.parse(raw_payload)
|
||||
|
||||
if isinstance(parse_result, JiraPayloadSkipped):
|
||||
logger.info(
|
||||
'[Jira] Webhook skipped', extra={'reason': parse_result.skip_reason}
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(parse_result, JiraPayloadError):
|
||||
logger.warning(
|
||||
'[Jira] Webhook parse failed', extra={'error': parse_result.error}
|
||||
)
|
||||
return
|
||||
|
||||
payload = parse_result.payload
|
||||
logger.info(
|
||||
'[Jira] Processing webhook',
|
||||
extra={
|
||||
'event_type': payload.event_type.value,
|
||||
'issue_key': payload.issue_key,
|
||||
'user_email': payload.user_email,
|
||||
},
|
||||
)
|
||||
|
||||
# Step 2: Validate workspace
|
||||
workspace = await self._get_active_workspace(payload)
|
||||
if not workspace:
|
||||
return
|
||||
|
||||
# Step 3: Authenticate user
|
||||
jira_user, saas_user_auth = await self._authenticate_user(payload, workspace)
|
||||
if not jira_user or not saas_user_auth:
|
||||
return
|
||||
|
||||
# Step 4: Create view (includes issue details fetch and repo selection)
|
||||
decrypted_api_key = self.token_manager.decrypt_text(workspace.svc_acc_api_key)
|
||||
|
||||
try:
|
||||
view = await JiraFactory.create_view(
|
||||
payload=payload,
|
||||
workspace=workspace,
|
||||
user=jira_user,
|
||||
user_auth=saas_user_auth,
|
||||
decrypted_api_key=decrypted_api_key,
|
||||
)
|
||||
except RepositoryNotFoundError as e:
|
||||
logger.warning(
|
||||
'[Jira] Repository not found',
|
||||
extra={'issue_key': payload.issue_key, 'error': str(e)},
|
||||
)
|
||||
await self._send_error_from_payload(payload, workspace, str(e))
|
||||
return
|
||||
except StartingConvoException as e:
|
||||
logger.warning(
|
||||
'[Jira] View creation failed',
|
||||
extra={'issue_key': payload.issue_key, 'error': str(e)},
|
||||
)
|
||||
await self._send_error_from_payload(payload, workspace, str(e))
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'[Jira] Unexpected error creating view',
|
||||
extra={'issue_key': payload.issue_key, 'error': str(e)},
|
||||
exc_info=True,
|
||||
)
|
||||
await self._send_error_from_payload(
|
||||
payload,
|
||||
workspace,
|
||||
'Failed to initialize conversation. Please try again.',
|
||||
)
|
||||
return
|
||||
|
||||
# Step 5: Start job
|
||||
await self.start_job(view)
|
||||
|
||||
async def _get_active_workspace(
|
||||
self, payload: JiraWebhookPayload
|
||||
) -> JiraWorkspace | None:
|
||||
"""Validate and return the workspace for the webhook.
|
||||
|
||||
Returns None if:
|
||||
- Workspace not found
|
||||
- Workspace is inactive
|
||||
- Request is from service account (to prevent recursion)
|
||||
"""
|
||||
workspace = await self.integration_store.get_workspace_by_name(
|
||||
payload.workspace_name
|
||||
)
|
||||
|
||||
if not workspace:
|
||||
logger.warning(
|
||||
'[Jira] Workspace not found',
|
||||
extra={'workspace_name': payload.workspace_name},
|
||||
)
|
||||
# Can't send error without workspace credentials
|
||||
return None
|
||||
|
||||
# Prevent recursive triggers from service account
|
||||
if payload.user_email == workspace.svc_acc_email:
|
||||
logger.debug(
|
||||
'[Jira] Ignoring service account trigger',
|
||||
extra={'workspace_name': payload.workspace_name},
|
||||
)
|
||||
return None
|
||||
|
||||
if workspace.status != 'active':
|
||||
logger.warning(
|
||||
'[Jira] Workspace inactive',
|
||||
extra={'workspace_id': workspace.id, 'status': workspace.status},
|
||||
)
|
||||
await self._send_error_from_payload(
|
||||
payload, workspace, 'Jira integration is not active for your workspace.'
|
||||
)
|
||||
return None
|
||||
|
||||
return workspace
|
||||
|
||||
async def _authenticate_user(
|
||||
self, payload: JiraWebhookPayload, workspace: JiraWorkspace
|
||||
) -> tuple[JiraUser | None, UserAuth | None]:
|
||||
"""Authenticate Jira user and get their OpenHands user auth."""
|
||||
|
||||
# Find active Jira user by Keycloak user ID and workspace ID
|
||||
"""Authenticate the Jira user and get OpenHands auth."""
|
||||
jira_user = await self.integration_store.get_active_user(
|
||||
jira_user_id, workspace_id
|
||||
payload.account_id, workspace.id
|
||||
)
|
||||
|
||||
if not jira_user:
|
||||
logger.warning(
|
||||
f'[Jira] No active Jira user found for {jira_user_id} in workspace {workspace_id}'
|
||||
'[Jira] User not found or inactive',
|
||||
extra={
|
||||
'account_id': payload.account_id,
|
||||
'user_email': payload.user_email,
|
||||
'workspace_id': workspace.id,
|
||||
},
|
||||
)
|
||||
await self._send_error_from_payload(
|
||||
payload,
|
||||
workspace,
|
||||
f'User {payload.user_email} is not authenticated or active in the Jira integration.',
|
||||
)
|
||||
return None, None
|
||||
|
||||
saas_user_auth = await get_user_auth_from_keycloak_id(
|
||||
jira_user.keycloak_user_id
|
||||
)
|
||||
|
||||
if not saas_user_auth:
|
||||
logger.warning(
|
||||
'[Jira] Failed to get OpenHands auth',
|
||||
extra={
|
||||
'keycloak_user_id': jira_user.keycloak_user_id,
|
||||
'user_email': payload.user_email,
|
||||
},
|
||||
)
|
||||
await self._send_error_from_payload(
|
||||
payload,
|
||||
workspace,
|
||||
f'User {payload.user_email} is not authenticated with OpenHands.',
|
||||
)
|
||||
return None, None
|
||||
|
||||
return jira_user, saas_user_auth
|
||||
|
||||
async def _get_repositories(self, user_auth: UserAuth) -> list[Repository]:
|
||||
"""Get repositories that the user has access to."""
|
||||
provider_tokens = await user_auth.get_provider_tokens()
|
||||
if provider_tokens is None:
|
||||
return []
|
||||
access_token = await user_auth.get_access_token()
|
||||
user_id = await user_auth.get_user_id()
|
||||
client = ProviderHandler(
|
||||
provider_tokens=provider_tokens,
|
||||
external_auth_token=access_token,
|
||||
external_auth_id=user_id,
|
||||
)
|
||||
repos: list[Repository] = await client.get_repositories(
|
||||
'pushed', server_config.app_mode, None, None, None, None
|
||||
)
|
||||
return repos
|
||||
|
||||
def get_workspace_name_from_payload(self, payload: dict) -> str | None:
|
||||
"""Extract workspace name from Jira webhook payload."""
|
||||
if payload.get('webhookEvent') == 'comment_created':
|
||||
selfUrl = payload.get('comment', {}).get('author', {}).get('self')
|
||||
elif payload.get('webhookEvent') == 'jira:issue_updated':
|
||||
selfUrl = payload.get('user', {}).get('self')
|
||||
else:
|
||||
return None
|
||||
|
||||
if not selfUrl:
|
||||
return None
|
||||
|
||||
parsedUrl = urlparse(selfUrl)
|
||||
return parsedUrl.hostname or None
|
||||
|
||||
def parse_webhook(self, message: Message) -> JobContext | None:
|
||||
payload = message.message.get('payload', {})
|
||||
issue_data = payload.get('issue', {})
|
||||
issue_id = issue_data.get('id')
|
||||
issue_key = issue_data.get('key')
|
||||
self_url = issue_data.get('self', '')
|
||||
if not self_url:
|
||||
logger.warning('[Jira] Missing self URL in issue data')
|
||||
base_api_url = ''
|
||||
elif '/rest/' in self_url:
|
||||
base_api_url = self_url.split('/rest/')[0]
|
||||
else:
|
||||
# Fallback: extract base URL using urlparse
|
||||
parsed = urlparse(self_url)
|
||||
base_api_url = f'{parsed.scheme}://{parsed.netloc}'
|
||||
|
||||
comment = ''
|
||||
if JiraFactory.is_ticket_comment(message):
|
||||
comment_data = payload.get('comment', {})
|
||||
comment = comment_data.get('body', '')
|
||||
user_data: dict = comment_data.get('author', {})
|
||||
elif JiraFactory.is_labeled_ticket(message):
|
||||
user_data = payload.get('user', {})
|
||||
|
||||
else:
|
||||
raise ValueError('Unrecognized jira event')
|
||||
|
||||
user_email = user_data.get('emailAddress')
|
||||
display_name = user_data.get('displayName')
|
||||
account_id = user_data.get('accountId')
|
||||
|
||||
workspace_name = ''
|
||||
parsedUrl = urlparse(base_api_url)
|
||||
if parsedUrl.hostname:
|
||||
workspace_name = parsedUrl.hostname
|
||||
|
||||
if not all(
|
||||
[
|
||||
issue_id,
|
||||
issue_key,
|
||||
user_email,
|
||||
display_name,
|
||||
account_id,
|
||||
workspace_name,
|
||||
base_api_url,
|
||||
]
|
||||
):
|
||||
return None
|
||||
|
||||
return JobContext(
|
||||
issue_id=issue_id,
|
||||
issue_key=issue_key,
|
||||
user_msg=comment,
|
||||
user_email=user_email,
|
||||
display_name=display_name,
|
||||
platform_user_id=account_id,
|
||||
workspace_name=workspace_name,
|
||||
base_api_url=base_api_url,
|
||||
)
|
||||
|
||||
async def is_job_requested(self, message: Message) -> bool:
|
||||
return JiraFactory.is_labeled_ticket(message) or JiraFactory.is_ticket_comment(
|
||||
message
|
||||
)
|
||||
|
||||
async def receive_message(self, message: Message):
|
||||
"""Process incoming Jira webhook message."""
|
||||
|
||||
payload = message.message.get('payload', {})
|
||||
logger.info('[Jira]: received payload', extra={'payload': payload})
|
||||
|
||||
is_job_requested = await self.is_job_requested(message)
|
||||
if not is_job_requested:
|
||||
return
|
||||
|
||||
job_context = self.parse_webhook(message)
|
||||
|
||||
if not job_context:
|
||||
logger.info(
|
||||
'[Jira] Failed to parse webhook payload - missing required fields or invalid structure',
|
||||
extra={'event_type': payload.get('webhookEvent')},
|
||||
)
|
||||
return
|
||||
|
||||
# Get workspace by user email domain
|
||||
workspace = await self.integration_store.get_workspace_by_name(
|
||||
job_context.workspace_name
|
||||
)
|
||||
if not workspace:
|
||||
logger.warning(
|
||||
f'[Jira] No workspace found for email domain: {job_context.user_email}'
|
||||
)
|
||||
await self._send_error_comment(
|
||||
job_context,
|
||||
'Your workspace is not configured with Jira integration.',
|
||||
None,
|
||||
)
|
||||
return
|
||||
|
||||
# Prevent any recursive triggers from the service account
|
||||
if job_context.user_email == workspace.svc_acc_email:
|
||||
return
|
||||
|
||||
if workspace.status != 'active':
|
||||
logger.warning(f'[Jira] Workspace {workspace.id} is not active')
|
||||
await self._send_error_comment(
|
||||
job_context,
|
||||
'Jira integration is not active for your workspace.',
|
||||
workspace,
|
||||
)
|
||||
return
|
||||
|
||||
# Authenticate user
|
||||
jira_user, saas_user_auth = await self.authenticate_user(
|
||||
job_context.platform_user_id, workspace.id
|
||||
)
|
||||
if not jira_user or not saas_user_auth:
|
||||
logger.warning(
|
||||
f'[Jira] User authentication failed for {job_context.user_email}'
|
||||
)
|
||||
await self._send_error_comment(
|
||||
job_context,
|
||||
f'User {job_context.user_email} is not authenticated or active in the Jira integration.',
|
||||
workspace,
|
||||
)
|
||||
return
|
||||
|
||||
# Get issue details
|
||||
try:
|
||||
api_key = self.token_manager.decrypt_text(workspace.svc_acc_api_key)
|
||||
issue_title, issue_description = await self.get_issue_details(
|
||||
job_context, workspace.jira_cloud_id, workspace.svc_acc_email, api_key
|
||||
)
|
||||
job_context.issue_title = issue_title
|
||||
job_context.issue_description = issue_description
|
||||
except Exception as e:
|
||||
logger.error(f'[Jira] Failed to get issue context: {str(e)}')
|
||||
await self._send_error_comment(
|
||||
job_context,
|
||||
'Failed to retrieve issue details. Please check the issue key and try again.',
|
||||
workspace,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
# Create Jira view
|
||||
jira_view = await JiraFactory.create_jira_view_from_payload(
|
||||
job_context,
|
||||
saas_user_auth,
|
||||
jira_user,
|
||||
workspace,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'[Jira] Failed to create jira view: {str(e)}', exc_info=True)
|
||||
await self._send_error_comment(
|
||||
job_context,
|
||||
'Failed to initialize conversation. Please try again.',
|
||||
workspace,
|
||||
)
|
||||
return
|
||||
|
||||
if not await self.is_repository_specified(message, jira_view):
|
||||
return
|
||||
|
||||
await self.start_job(jira_view)
|
||||
|
||||
async def is_repository_specified(
|
||||
self, message: Message, jira_view: JiraViewInterface
|
||||
) -> bool:
|
||||
"""
|
||||
Check if a job is requested and handle repository selection.
|
||||
"""
|
||||
|
||||
try:
|
||||
# Get user repositories
|
||||
user_repos: list[Repository] = await self._get_repositories(
|
||||
jira_view.saas_user_auth
|
||||
)
|
||||
|
||||
target_str = f'{jira_view.job_context.issue_description}\n{jira_view.job_context.user_msg}'
|
||||
|
||||
# Try to infer repository from issue description
|
||||
match, repos = filter_potential_repos_by_user_msg(target_str, user_repos)
|
||||
|
||||
if match:
|
||||
# Found exact repository match
|
||||
jira_view.selected_repo = repos[0].full_name
|
||||
logger.info(f'[Jira] Inferred repository: {repos[0].full_name}')
|
||||
return True
|
||||
else:
|
||||
# No clear match - send repository selection comment
|
||||
await self._send_repo_selection_comment(jira_view)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'[Jira] Error determining repository: {str(e)}')
|
||||
return False
|
||||
|
||||
async def start_job(self, jira_view: JiraViewInterface):
|
||||
async def start_job(self, view: JiraViewInterface):
|
||||
"""Start a Jira job/conversation."""
|
||||
# Import here to prevent circular import
|
||||
from server.conversation_callback_processor.jira_callback_processor import (
|
||||
@@ -305,101 +265,79 @@ class JiraManager(Manager):
|
||||
)
|
||||
|
||||
try:
|
||||
user_info: JiraUser = jira_view.jira_user
|
||||
logger.info(
|
||||
f'[Jira] Starting job for user {user_info.keycloak_user_id} '
|
||||
f'issue {jira_view.job_context.issue_key}',
|
||||
'[Jira] Starting job',
|
||||
extra={
|
||||
'issue_key': view.payload.issue_key,
|
||||
'user_id': view.jira_user.keycloak_user_id,
|
||||
'selected_repo': view.selected_repo,
|
||||
},
|
||||
)
|
||||
|
||||
# Create conversation
|
||||
conversation_id = await jira_view.create_or_update_conversation(
|
||||
self.jinja_env
|
||||
)
|
||||
conversation_id = await view.create_or_update_conversation(self.jinja_env)
|
||||
|
||||
logger.info(
|
||||
f'[Jira] Created/Updated conversation {conversation_id} for issue {jira_view.job_context.issue_key}'
|
||||
'[Jira] Conversation created',
|
||||
extra={
|
||||
'conversation_id': conversation_id,
|
||||
'issue_key': view.payload.issue_key,
|
||||
},
|
||||
)
|
||||
|
||||
# Register callback processor for updates
|
||||
if isinstance(jira_view, JiraNewConversationView):
|
||||
if isinstance(view, JiraNewConversationView):
|
||||
processor = JiraCallbackProcessor(
|
||||
issue_key=jira_view.job_context.issue_key,
|
||||
workspace_name=jira_view.jira_workspace.name,
|
||||
issue_key=view.payload.issue_key,
|
||||
workspace_name=view.jira_workspace.name,
|
||||
)
|
||||
|
||||
# Register the callback processor
|
||||
register_callback_processor(conversation_id, processor)
|
||||
|
||||
logger.info(
|
||||
f'[Jira] Created callback processor for conversation {conversation_id}'
|
||||
'[Jira] Callback processor registered',
|
||||
extra={'conversation_id': conversation_id},
|
||||
)
|
||||
|
||||
# Send initial response
|
||||
msg_info = jira_view.get_response_msg()
|
||||
# Send success response
|
||||
msg_info = view.get_response_msg()
|
||||
|
||||
except MissingSettingsError as e:
|
||||
logger.warning(f'[Jira] Missing settings error: {str(e)}')
|
||||
logger.warning(
|
||||
'[Jira] Missing settings error',
|
||||
extra={'issue_key': view.payload.issue_key, 'error': str(e)},
|
||||
)
|
||||
msg_info = f'Please re-login into [OpenHands Cloud]({HOST_URL}) before starting a job.'
|
||||
|
||||
except LLMAuthenticationError as e:
|
||||
logger.warning(f'[Jira] LLM authentication error: {str(e)}')
|
||||
logger.warning(
|
||||
'[Jira] LLM authentication error',
|
||||
extra={'issue_key': view.payload.issue_key, 'error': str(e)},
|
||||
)
|
||||
msg_info = f'Please set a valid LLM API key in [OpenHands Cloud]({HOST_URL}) before starting a job.'
|
||||
|
||||
except SessionExpiredError as e:
|
||||
logger.warning(f'[Jira] Session expired: {str(e)}')
|
||||
logger.warning(
|
||||
'[Jira] Session expired',
|
||||
extra={'issue_key': view.payload.issue_key, 'error': str(e)},
|
||||
)
|
||||
msg_info = get_session_expired_message()
|
||||
|
||||
except StartingConvoException as e:
|
||||
logger.warning(
|
||||
'[Jira] Conversation start failed',
|
||||
extra={'issue_key': view.payload.issue_key, 'error': str(e)},
|
||||
)
|
||||
msg_info = str(e)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'[Jira] Unexpected error starting job: {str(e)}', exc_info=True
|
||||
'[Jira] Unexpected error starting job',
|
||||
extra={'issue_key': view.payload.issue_key, 'error': str(e)},
|
||||
exc_info=True,
|
||||
)
|
||||
msg_info = 'Sorry, there was an unexpected error starting the job. Please try again.'
|
||||
|
||||
# Send response comment
|
||||
try:
|
||||
api_key = self.token_manager.decrypt_text(
|
||||
jira_view.jira_workspace.svc_acc_api_key
|
||||
)
|
||||
await self.send_message(
|
||||
self.create_outgoing_message(msg=msg_info),
|
||||
issue_key=jira_view.job_context.issue_key,
|
||||
jira_cloud_id=jira_view.jira_workspace.jira_cloud_id,
|
||||
svc_acc_email=jira_view.jira_workspace.svc_acc_email,
|
||||
svc_acc_api_key=api_key,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'[Jira] Failed to send response message: {str(e)}')
|
||||
|
||||
async def get_issue_details(
|
||||
self,
|
||||
job_context: JobContext,
|
||||
jira_cloud_id: str,
|
||||
svc_acc_email: str,
|
||||
svc_acc_api_key: str,
|
||||
) -> Tuple[str, str]:
|
||||
url = f'{JIRA_CLOUD_API_URL}/{jira_cloud_id}/rest/api/2/issue/{job_context.issue_key}'
|
||||
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
|
||||
response = await client.get(url, auth=(svc_acc_email, svc_acc_api_key))
|
||||
response.raise_for_status()
|
||||
issue_payload = response.json()
|
||||
|
||||
if not issue_payload:
|
||||
raise ValueError(f'Issue with key {job_context.issue_key} not found.')
|
||||
|
||||
title = issue_payload.get('fields', {}).get('summary', '')
|
||||
description = issue_payload.get('fields', {}).get('description', '')
|
||||
|
||||
if not title:
|
||||
raise ValueError(
|
||||
f'Issue with key {job_context.issue_key} does not have a title.'
|
||||
)
|
||||
|
||||
if not description:
|
||||
raise ValueError(
|
||||
f'Issue with key {job_context.issue_key} does not have a description.'
|
||||
)
|
||||
|
||||
return title, description
|
||||
await self._send_comment(view, msg_info)
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
@@ -409,6 +347,7 @@ class JiraManager(Manager):
|
||||
svc_acc_email: str,
|
||||
svc_acc_api_key: str,
|
||||
):
|
||||
"""Send a comment to a Jira issue."""
|
||||
url = (
|
||||
f'{JIRA_CLOUD_API_URL}/{jira_cloud_id}/rest/api/2/issue/{issue_key}/comment'
|
||||
)
|
||||
@@ -420,54 +359,53 @@ class JiraManager(Manager):
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def _send_error_comment(
|
||||
self,
|
||||
job_context: JobContext,
|
||||
error_msg: str,
|
||||
workspace: JiraWorkspace | None,
|
||||
):
|
||||
"""Send error comment to Jira issue."""
|
||||
if not workspace:
|
||||
logger.error('[Jira] Cannot send error comment - no workspace available')
|
||||
return
|
||||
async def _send_comment(self, view: JiraViewInterface, msg: str):
|
||||
"""Send a comment using credentials from the view."""
|
||||
try:
|
||||
api_key = self.token_manager.decrypt_text(
|
||||
view.jira_workspace.svc_acc_api_key
|
||||
)
|
||||
await self.send_message(
|
||||
self.create_outgoing_message(msg=msg),
|
||||
issue_key=view.payload.issue_key,
|
||||
jira_cloud_id=view.jira_workspace.jira_cloud_id,
|
||||
svc_acc_email=view.jira_workspace.svc_acc_email,
|
||||
svc_acc_api_key=api_key,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'[Jira] Failed to send comment',
|
||||
extra={'issue_key': view.payload.issue_key, 'error': str(e)},
|
||||
)
|
||||
|
||||
async def _send_error_from_payload(
|
||||
self,
|
||||
payload: JiraWebhookPayload,
|
||||
workspace: JiraWorkspace,
|
||||
error_msg: str,
|
||||
):
|
||||
"""Send error comment before view is created (using payload directly)."""
|
||||
try:
|
||||
api_key = self.token_manager.decrypt_text(workspace.svc_acc_api_key)
|
||||
await self.send_message(
|
||||
self.create_outgoing_message(msg=error_msg),
|
||||
issue_key=job_context.issue_key,
|
||||
issue_key=payload.issue_key,
|
||||
jira_cloud_id=workspace.jira_cloud_id,
|
||||
svc_acc_email=workspace.svc_acc_email,
|
||||
svc_acc_api_key=api_key,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'[Jira] Failed to send error comment: {str(e)}')
|
||||
|
||||
async def _send_repo_selection_comment(self, jira_view: JiraViewInterface):
|
||||
"""Send a comment with repository options for the user to choose."""
|
||||
try:
|
||||
comment_msg = (
|
||||
'I need to know which repository to work with. '
|
||||
'Please add it to your issue description or send a followup comment.'
|
||||
)
|
||||
|
||||
api_key = self.token_manager.decrypt_text(
|
||||
jira_view.jira_workspace.svc_acc_api_key
|
||||
)
|
||||
|
||||
await self.send_message(
|
||||
self.create_outgoing_message(msg=comment_msg),
|
||||
issue_key=jira_view.job_context.issue_key,
|
||||
jira_cloud_id=jira_view.jira_workspace.jira_cloud_id,
|
||||
svc_acc_email=jira_view.jira_workspace.svc_acc_email,
|
||||
svc_acc_api_key=api_key,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'[Jira] Sent repository selection comment for issue {jira_view.job_context.issue_key}'
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'[Jira] Failed to send repository selection comment: {str(e)}'
|
||||
'[Jira] Failed to send error comment',
|
||||
extra={'issue_key': payload.issue_key, 'error': str(e)},
|
||||
)
|
||||
|
||||
def get_workspace_name_from_payload(self, payload: dict) -> str | None:
|
||||
"""Extract workspace name from Jira webhook payload.
|
||||
|
||||
This method is used by the route for signature verification.
|
||||
"""
|
||||
parse_result = self.payload_parser.parse(payload)
|
||||
if isinstance(parse_result, JiraPayloadSuccess):
|
||||
return parse_result.payload.workspace_name
|
||||
return None
|
||||
|
||||
267
enterprise/integrations/jira/jira_payload.py
Normal file
267
enterprise/integrations/jira/jira_payload.py
Normal file
@@ -0,0 +1,267 @@
|
||||
"""Centralized payload parsing for Jira webhooks.
|
||||
|
||||
This module provides a single source of truth for parsing and validating
|
||||
Jira webhook payloads, replacing scattered parsing logic throughout the codebase.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
class JiraEventType(Enum):
|
||||
"""Types of Jira events we handle."""
|
||||
|
||||
LABELED_TICKET = 'labeled_ticket'
|
||||
COMMENT_MENTION = 'comment_mention'
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JiraWebhookPayload:
|
||||
"""Normalized, validated representation of a Jira webhook payload.
|
||||
|
||||
This immutable dataclass replaces JobContext and provides a single
|
||||
source of truth for all webhook data. All parsing happens in
|
||||
JiraPayloadParser, ensuring consistent validation.
|
||||
"""
|
||||
|
||||
event_type: JiraEventType
|
||||
raw_event: str # Original webhookEvent value
|
||||
|
||||
# Issue data
|
||||
issue_id: str
|
||||
issue_key: str
|
||||
|
||||
# User data
|
||||
user_email: str
|
||||
display_name: str
|
||||
account_id: str
|
||||
|
||||
# Workspace data (derived from issue self URL)
|
||||
workspace_name: str
|
||||
base_api_url: str
|
||||
|
||||
# Event-specific data
|
||||
comment_body: str = '' # For comment events
|
||||
|
||||
@property
|
||||
def user_msg(self) -> str:
|
||||
"""Alias for comment_body for backward compatibility."""
|
||||
return self.comment_body
|
||||
|
||||
|
||||
class JiraPayloadParseError(Exception):
|
||||
"""Raised when payload parsing fails."""
|
||||
|
||||
def __init__(self, reason: str, event_type: str | None = None):
|
||||
self.reason = reason
|
||||
self.event_type = event_type
|
||||
super().__init__(reason)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JiraPayloadSuccess:
|
||||
"""Result when parsing succeeds."""
|
||||
|
||||
payload: JiraWebhookPayload
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JiraPayloadSkipped:
|
||||
"""Result when event is intentionally skipped."""
|
||||
|
||||
skip_reason: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JiraPayloadError:
|
||||
"""Result when parsing fails due to invalid data."""
|
||||
|
||||
error: str
|
||||
|
||||
|
||||
JiraPayloadParseResult = JiraPayloadSuccess | JiraPayloadSkipped | JiraPayloadError
|
||||
|
||||
|
||||
class JiraPayloadParser:
|
||||
"""Centralized parser for Jira webhook payloads.
|
||||
|
||||
This class provides a single entry point for parsing webhooks,
|
||||
determining event types, and extracting all necessary fields.
|
||||
Replaces scattered parsing in JiraFactory and JiraManager.
|
||||
"""
|
||||
|
||||
def __init__(self, oh_label: str, inline_oh_label: str):
|
||||
"""Initialize parser with OpenHands label configuration.
|
||||
|
||||
Args:
|
||||
oh_label: Label that triggers OpenHands (e.g., 'openhands')
|
||||
inline_oh_label: Mention that triggers OpenHands (e.g., '@openhands')
|
||||
"""
|
||||
self.oh_label = oh_label
|
||||
self.inline_oh_label = inline_oh_label
|
||||
|
||||
def parse(self, raw_payload: dict) -> JiraPayloadParseResult:
|
||||
"""Parse a raw webhook payload into a normalized JiraWebhookPayload.
|
||||
|
||||
Args:
|
||||
raw_payload: The raw webhook payload dict from Jira
|
||||
|
||||
Returns:
|
||||
One of:
|
||||
- JiraPayloadSuccess: Valid, actionable event with payload
|
||||
- JiraPayloadSkipped: Event we intentionally don't process
|
||||
- JiraPayloadError: Malformed payload we expected to process
|
||||
"""
|
||||
webhook_event = raw_payload.get('webhookEvent', '')
|
||||
|
||||
logger.debug(
|
||||
'[Jira] Parsing webhook payload', extra={'webhook_event': webhook_event}
|
||||
)
|
||||
|
||||
if webhook_event == 'jira:issue_updated':
|
||||
return self._parse_label_event(raw_payload, webhook_event)
|
||||
elif webhook_event == 'comment_created':
|
||||
return self._parse_comment_event(raw_payload, webhook_event)
|
||||
else:
|
||||
return JiraPayloadSkipped(f'Unhandled webhook event type: {webhook_event}')
|
||||
|
||||
def _parse_label_event(
|
||||
self, payload: dict, webhook_event: str
|
||||
) -> JiraPayloadParseResult:
|
||||
"""Parse an issue_updated event for label changes."""
|
||||
changelog = payload.get('changelog', {})
|
||||
items = changelog.get('items', [])
|
||||
|
||||
# Extract labels that were added
|
||||
labels = [
|
||||
item.get('toString', '')
|
||||
for item in items
|
||||
if item.get('field') == 'labels' and 'toString' in item
|
||||
]
|
||||
|
||||
if self.oh_label not in labels:
|
||||
return JiraPayloadSkipped(
|
||||
f"Label event does not contain '{self.oh_label}' label"
|
||||
)
|
||||
|
||||
# For label events, user data comes from 'user' field
|
||||
user_data = payload.get('user', {})
|
||||
return self._extract_and_validate(
|
||||
payload=payload,
|
||||
user_data=user_data,
|
||||
event_type=JiraEventType.LABELED_TICKET,
|
||||
webhook_event=webhook_event,
|
||||
comment_body='',
|
||||
)
|
||||
|
||||
def _parse_comment_event(
|
||||
self, payload: dict, webhook_event: str
|
||||
) -> JiraPayloadParseResult:
|
||||
"""Parse a comment_created event."""
|
||||
comment_data = payload.get('comment', {})
|
||||
comment_body = comment_data.get('body', '')
|
||||
|
||||
if not self._has_mention(comment_body):
|
||||
return JiraPayloadSkipped(
|
||||
f"Comment does not mention '{self.inline_oh_label}'"
|
||||
)
|
||||
|
||||
# For comment events, user data comes from 'comment.author'
|
||||
user_data = comment_data.get('author', {})
|
||||
return self._extract_and_validate(
|
||||
payload=payload,
|
||||
user_data=user_data,
|
||||
event_type=JiraEventType.COMMENT_MENTION,
|
||||
webhook_event=webhook_event,
|
||||
comment_body=comment_body,
|
||||
)
|
||||
|
||||
def _has_mention(self, text: str) -> bool:
|
||||
"""Check if text contains an exact mention of OpenHands."""
|
||||
from integrations.utils import has_exact_mention
|
||||
|
||||
return has_exact_mention(text, self.inline_oh_label)
|
||||
|
||||
def _extract_and_validate(
|
||||
self,
|
||||
payload: dict,
|
||||
user_data: dict,
|
||||
event_type: JiraEventType,
|
||||
webhook_event: str,
|
||||
comment_body: str,
|
||||
) -> JiraPayloadParseResult:
|
||||
"""Extract common fields and validate required data is present."""
|
||||
issue_data = payload.get('issue', {})
|
||||
|
||||
# Extract all fields with empty string defaults (makes them str type)
|
||||
issue_id = issue_data.get('id', '')
|
||||
issue_key = issue_data.get('key', '')
|
||||
user_email = user_data.get('emailAddress', '')
|
||||
display_name = user_data.get('displayName', '')
|
||||
account_id = user_data.get('accountId', '')
|
||||
base_api_url, workspace_name = self._extract_workspace_from_url(
|
||||
issue_data.get('self', '')
|
||||
)
|
||||
|
||||
# Validate required fields
|
||||
missing: list[str] = []
|
||||
if not issue_id:
|
||||
missing.append('issue.id')
|
||||
if not issue_key:
|
||||
missing.append('issue.key')
|
||||
if not user_email:
|
||||
missing.append('user.emailAddress')
|
||||
if not display_name:
|
||||
missing.append('user.displayName')
|
||||
if not account_id:
|
||||
missing.append('user.accountId')
|
||||
if not workspace_name:
|
||||
missing.append('workspace_name (derived from issue.self)')
|
||||
if not base_api_url:
|
||||
missing.append('base_api_url (derived from issue.self)')
|
||||
|
||||
if missing:
|
||||
return JiraPayloadError(f"Missing required fields: {', '.join(missing)}")
|
||||
|
||||
return JiraPayloadSuccess(
|
||||
JiraWebhookPayload(
|
||||
event_type=event_type,
|
||||
raw_event=webhook_event,
|
||||
issue_id=issue_id,
|
||||
issue_key=issue_key,
|
||||
user_email=user_email,
|
||||
display_name=display_name,
|
||||
account_id=account_id,
|
||||
workspace_name=workspace_name,
|
||||
base_api_url=base_api_url,
|
||||
comment_body=comment_body,
|
||||
)
|
||||
)
|
||||
|
||||
def _extract_workspace_from_url(self, self_url: str) -> tuple[str, str]:
|
||||
"""Extract base API URL and workspace name from issue self URL.
|
||||
|
||||
Args:
|
||||
self_url: The 'self' URL from the issue data
|
||||
|
||||
Returns:
|
||||
Tuple of (base_api_url, workspace_name)
|
||||
"""
|
||||
if not self_url:
|
||||
return '', ''
|
||||
|
||||
# Extract base URL (everything before /rest/)
|
||||
if '/rest/' in self_url:
|
||||
base_api_url = self_url.split('/rest/')[0]
|
||||
else:
|
||||
parsed = urlparse(self_url)
|
||||
base_api_url = f'{parsed.scheme}://{parsed.netloc}'
|
||||
|
||||
# Extract workspace name (hostname)
|
||||
parsed = urlparse(base_api_url)
|
||||
workspace_name = parsed.hostname or ''
|
||||
|
||||
return base_api_url, workspace_name
|
||||
@@ -1,26 +1,42 @@
|
||||
from abc import ABC, abstractmethod
|
||||
"""Type definitions and interfaces for Jira integration."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from integrations.models import JobContext
|
||||
from jinja2 import Environment
|
||||
from storage.jira_user import JiraUser
|
||||
from storage.jira_workspace import JiraWorkspace
|
||||
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from integrations.jira.jira_payload import JiraWebhookPayload
|
||||
|
||||
|
||||
class JiraViewInterface(ABC):
|
||||
"""Interface for Jira views that handle different types of Jira interactions."""
|
||||
"""Interface for Jira views that handle different types of Jira interactions.
|
||||
|
||||
job_context: JobContext
|
||||
Views hold the webhook payload directly rather than duplicating fields,
|
||||
and fetch issue details lazily when needed.
|
||||
"""
|
||||
|
||||
# Core data - view holds these references
|
||||
payload: 'JiraWebhookPayload'
|
||||
saas_user_auth: UserAuth
|
||||
jira_user: JiraUser
|
||||
jira_workspace: JiraWorkspace
|
||||
|
||||
# Mutable state set during processing
|
||||
selected_repo: str | None
|
||||
conversation_id: str
|
||||
|
||||
@abstractmethod
|
||||
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
"""Get initial instructions for the conversation."""
|
||||
async def get_issue_details(self) -> tuple[str, str]:
|
||||
"""Fetch and cache issue title and description from Jira API.
|
||||
|
||||
Returns:
|
||||
Tuple of (issue_title, issue_description)
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
@@ -35,6 +51,21 @@ class JiraViewInterface(ABC):
|
||||
|
||||
|
||||
class StartingConvoException(Exception):
|
||||
"""Exception raised when starting a conversation fails."""
|
||||
"""Exception raised when starting a conversation fails.
|
||||
|
||||
This provides user-friendly error messages that can be sent back to Jira.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class RepositoryNotFoundError(Exception):
|
||||
"""Raised when a repository cannot be determined from the issue.
|
||||
|
||||
This is a separate error domain from StartingConvoException - it represents
|
||||
a precondition failure (no repo configured/found) rather than a conversation
|
||||
creation failure. The manager catches this and converts it to a user-friendly
|
||||
message.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
from dataclasses import dataclass
|
||||
"""Jira view implementations and factory.
|
||||
|
||||
from integrations.jira.jira_types import JiraViewInterface, StartingConvoException
|
||||
from integrations.models import JobContext, Message
|
||||
from integrations.utils import CONVERSATION_URL, HOST, get_oh_labels, has_exact_mention
|
||||
Views are responsible for:
|
||||
- Holding the webhook payload and auth context
|
||||
- Lazy-loading issue details from Jira API when needed
|
||||
- Creating conversations with the selected repository
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import httpx
|
||||
from integrations.jira.jira_payload import JiraWebhookPayload
|
||||
from integrations.jira.jira_types import (
|
||||
JiraViewInterface,
|
||||
RepositoryNotFoundError,
|
||||
StartingConvoException,
|
||||
)
|
||||
from integrations.utils import CONVERSATION_URL, infer_repo_from_message
|
||||
from jinja2 import Environment
|
||||
from storage.jira_conversation import JiraConversation
|
||||
from storage.jira_integration_store import JiraIntegrationStore
|
||||
@@ -10,52 +23,147 @@ from storage.jira_user import JiraUser
|
||||
from storage.jira_workspace import JiraWorkspace
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.services.conversation_service import (
|
||||
create_new_conversation,
|
||||
)
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.server.services.conversation_service import create_new_conversation
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
JIRA_CLOUD_API_URL = 'https://api.atlassian.com/ex/jira'
|
||||
|
||||
integration_store = JiraIntegrationStore.get_instance()
|
||||
|
||||
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
|
||||
|
||||
|
||||
@dataclass
|
||||
class JiraNewConversationView(JiraViewInterface):
|
||||
job_context: JobContext
|
||||
"""View for creating a new Jira conversation.
|
||||
|
||||
This view holds the webhook payload directly and lazily fetches
|
||||
issue details when needed for rendering templates.
|
||||
"""
|
||||
|
||||
payload: JiraWebhookPayload
|
||||
saas_user_auth: UserAuth
|
||||
jira_user: JiraUser
|
||||
jira_workspace: JiraWorkspace
|
||||
selected_repo: str | None
|
||||
conversation_id: str
|
||||
selected_repo: str | None = None
|
||||
conversation_id: str = ''
|
||||
|
||||
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
"""Instructions passed when conversation is first initialized"""
|
||||
# Lazy-loaded issue details (cached after first fetch)
|
||||
_issue_title: str | None = field(default=None, repr=False)
|
||||
_issue_description: str | None = field(default=None, repr=False)
|
||||
|
||||
# Decrypted API key (set by factory)
|
||||
_decrypted_api_key: str = field(default='', repr=False)
|
||||
|
||||
async def get_issue_details(self) -> tuple[str, str]:
|
||||
"""Fetch issue details from Jira API (cached after first call).
|
||||
|
||||
Returns:
|
||||
Tuple of (issue_title, issue_description)
|
||||
|
||||
Raises:
|
||||
StartingConvoException: If issue details cannot be fetched
|
||||
"""
|
||||
if self._issue_title is not None and self._issue_description is not None:
|
||||
return self._issue_title, self._issue_description
|
||||
|
||||
try:
|
||||
url = f'{JIRA_CLOUD_API_URL}/{self.jira_workspace.jira_cloud_id}/rest/api/2/issue/{self.payload.issue_key}'
|
||||
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
|
||||
response = await client.get(
|
||||
url,
|
||||
auth=(
|
||||
self.jira_workspace.svc_acc_email,
|
||||
self._decrypted_api_key,
|
||||
),
|
||||
)
|
||||
response.raise_for_status()
|
||||
issue_payload = response.json()
|
||||
|
||||
if not issue_payload:
|
||||
raise StartingConvoException(
|
||||
f'Issue {self.payload.issue_key} not found.'
|
||||
)
|
||||
|
||||
self._issue_title = issue_payload.get('fields', {}).get('summary', '')
|
||||
self._issue_description = (
|
||||
issue_payload.get('fields', {}).get('description', '') or ''
|
||||
)
|
||||
|
||||
if not self._issue_title:
|
||||
raise StartingConvoException(
|
||||
f'Issue {self.payload.issue_key} does not have a title.'
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'[Jira] Fetched issue details',
|
||||
extra={
|
||||
'issue_key': self.payload.issue_key,
|
||||
'has_description': bool(self._issue_description),
|
||||
},
|
||||
)
|
||||
|
||||
return self._issue_title, self._issue_description
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(
|
||||
'[Jira] Failed to fetch issue details',
|
||||
extra={
|
||||
'issue_key': self.payload.issue_key,
|
||||
'status': e.response.status_code,
|
||||
},
|
||||
)
|
||||
raise StartingConvoException(
|
||||
f'Failed to fetch issue details: HTTP {e.response.status_code}'
|
||||
)
|
||||
except Exception as e:
|
||||
if isinstance(e, StartingConvoException):
|
||||
raise
|
||||
logger.error(
|
||||
'[Jira] Failed to fetch issue details',
|
||||
extra={'issue_key': self.payload.issue_key, 'error': str(e)},
|
||||
)
|
||||
raise StartingConvoException(f'Failed to fetch issue details: {str(e)}')
|
||||
|
||||
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
"""Get instructions for the conversation.
|
||||
|
||||
This fetches issue details if not already cached.
|
||||
|
||||
Returns:
|
||||
Tuple of (system_instructions, user_message)
|
||||
"""
|
||||
issue_title, issue_description = await self.get_issue_details()
|
||||
|
||||
instructions_template = jinja_env.get_template('jira_instructions.j2')
|
||||
instructions = instructions_template.render()
|
||||
|
||||
user_msg_template = jinja_env.get_template('jira_new_conversation.j2')
|
||||
|
||||
user_msg = user_msg_template.render(
|
||||
issue_key=self.job_context.issue_key,
|
||||
issue_title=self.job_context.issue_title,
|
||||
issue_description=self.job_context.issue_description,
|
||||
user_message=self.job_context.user_msg or '',
|
||||
issue_key=self.payload.issue_key,
|
||||
issue_title=issue_title,
|
||||
issue_description=issue_description,
|
||||
user_message=self.payload.user_msg,
|
||||
)
|
||||
|
||||
return instructions, user_msg
|
||||
|
||||
async def create_or_update_conversation(self, jinja_env: Environment) -> str:
|
||||
"""Create a new Jira conversation"""
|
||||
"""Create a new Jira conversation.
|
||||
|
||||
Returns:
|
||||
The conversation ID
|
||||
|
||||
Raises:
|
||||
StartingConvoException: If conversation creation fails
|
||||
"""
|
||||
if not self.selected_repo:
|
||||
raise StartingConvoException('No repository selected for this conversation')
|
||||
|
||||
provider_tokens = await self.saas_user_auth.get_provider_tokens()
|
||||
user_secrets = await self.saas_user_auth.get_secrets()
|
||||
instructions, user_msg = self._get_instructions(jinja_env)
|
||||
instructions, user_msg = await self._get_instructions(jinja_env)
|
||||
|
||||
try:
|
||||
agent_loop_info = await create_new_conversation(
|
||||
@@ -73,81 +181,259 @@ class JiraNewConversationView(JiraViewInterface):
|
||||
|
||||
self.conversation_id = agent_loop_info.conversation_id
|
||||
|
||||
logger.info(f'[Jira] Created conversation {self.conversation_id}')
|
||||
logger.info(
|
||||
'[Jira] Created conversation',
|
||||
extra={
|
||||
'conversation_id': self.conversation_id,
|
||||
'issue_key': self.payload.issue_key,
|
||||
'selected_repo': self.selected_repo,
|
||||
},
|
||||
)
|
||||
|
||||
# Store Jira conversation mapping
|
||||
jira_conversation = JiraConversation(
|
||||
conversation_id=self.conversation_id,
|
||||
issue_id=self.job_context.issue_id,
|
||||
issue_key=self.job_context.issue_key,
|
||||
issue_id=self.payload.issue_id,
|
||||
issue_key=self.payload.issue_key,
|
||||
jira_user_id=self.jira_user.id,
|
||||
)
|
||||
|
||||
await integration_store.create_conversation(jira_conversation)
|
||||
|
||||
return self.conversation_id
|
||||
|
||||
except Exception as e:
|
||||
if isinstance(e, StartingConvoException):
|
||||
raise
|
||||
logger.error(
|
||||
f'[Jira] Failed to create conversation: {str(e)}', exc_info=True
|
||||
'[Jira] Failed to create conversation',
|
||||
extra={'issue_key': self.payload.issue_key, 'error': str(e)},
|
||||
exc_info=True,
|
||||
)
|
||||
raise StartingConvoException(f'Failed to create conversation: {str(e)}')
|
||||
|
||||
def get_response_msg(self) -> str:
|
||||
"""Get the response message to send back to Jira"""
|
||||
"""Get the response message to send back to Jira."""
|
||||
conversation_link = CONVERSATION_URL.format(self.conversation_id)
|
||||
return f"I'm on it! {self.job_context.display_name} can [track my progress here|{conversation_link}]."
|
||||
return f"I'm on it! {self.payload.display_name} can [track my progress here|{conversation_link}]."
|
||||
|
||||
|
||||
class JiraFactory:
|
||||
"""Factory for creating Jira views based on message content"""
|
||||
"""Factory for creating Jira views.
|
||||
|
||||
The factory is responsible for:
|
||||
- Creating the appropriate view type
|
||||
- Inferring and selecting the repository
|
||||
- Validating all required data is available
|
||||
|
||||
Repository selection happens here so that view creation either
|
||||
succeeds with a valid repo or fails with a clear error.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def is_labeled_ticket(message: Message) -> bool:
|
||||
payload = message.message.get('payload', {})
|
||||
event_type = payload.get('webhookEvent')
|
||||
async def _create_provider_handler(user_auth: UserAuth) -> ProviderHandler | None:
|
||||
"""Create a ProviderHandler for the user."""
|
||||
provider_tokens = await user_auth.get_provider_tokens()
|
||||
if provider_tokens is None:
|
||||
return None
|
||||
|
||||
if event_type != 'jira:issue_updated':
|
||||
return False
|
||||
access_token = await user_auth.get_access_token()
|
||||
user_id = await user_auth.get_user_id()
|
||||
|
||||
changelog = payload.get('changelog', {})
|
||||
items = changelog.get('items', [])
|
||||
labels = [
|
||||
item.get('toString', '')
|
||||
for item in items
|
||||
if item.get('field') == 'labels' and 'toString' in item
|
||||
]
|
||||
|
||||
return OH_LABEL in labels
|
||||
|
||||
@staticmethod
|
||||
def is_ticket_comment(message: Message) -> bool:
|
||||
payload = message.message.get('payload', {})
|
||||
event_type = payload.get('webhookEvent')
|
||||
|
||||
if event_type != 'comment_created':
|
||||
return False
|
||||
|
||||
comment_data = payload.get('comment', {})
|
||||
comment_body = comment_data.get('body', '')
|
||||
return has_exact_mention(comment_body, INLINE_OH_LABEL)
|
||||
|
||||
@staticmethod
|
||||
async def create_jira_view_from_payload(
|
||||
job_context: JobContext,
|
||||
saas_user_auth: UserAuth,
|
||||
jira_user: JiraUser,
|
||||
jira_workspace: JiraWorkspace,
|
||||
) -> JiraViewInterface:
|
||||
"""Create appropriate Jira view based on the message and user state"""
|
||||
|
||||
if not jira_user or not saas_user_auth or not jira_workspace:
|
||||
raise StartingConvoException('User not authenticated with Jira integration')
|
||||
|
||||
return JiraNewConversationView(
|
||||
job_context=job_context,
|
||||
saas_user_auth=saas_user_auth,
|
||||
jira_user=jira_user,
|
||||
jira_workspace=jira_workspace,
|
||||
selected_repo=None, # Will be set later after repo inference
|
||||
conversation_id='', # Will be set when conversation is created
|
||||
return ProviderHandler(
|
||||
provider_tokens=provider_tokens,
|
||||
external_auth_token=access_token,
|
||||
external_auth_id=user_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _extract_potential_repos(
|
||||
issue_key: str,
|
||||
issue_title: str,
|
||||
issue_description: str,
|
||||
user_msg: str,
|
||||
) -> list[str]:
|
||||
"""Extract potential repository names from issue content.
|
||||
|
||||
Raises:
|
||||
RepositoryNotFoundError: If no potential repos found in text.
|
||||
"""
|
||||
search_text = f'{issue_title}\n{issue_description}\n{user_msg}'
|
||||
potential_repos = infer_repo_from_message(search_text)
|
||||
|
||||
if not potential_repos:
|
||||
raise RepositoryNotFoundError(
|
||||
'Could not determine which repository to use. '
|
||||
'Please mention the repository (e.g., owner/repo) in the issue description or comment.'
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'[Jira] Found potential repositories in issue content',
|
||||
extra={'issue_key': issue_key, 'potential_repos': potential_repos},
|
||||
)
|
||||
return potential_repos
|
||||
|
||||
@staticmethod
|
||||
async def _verify_repos(
|
||||
issue_key: str,
|
||||
potential_repos: list[str],
|
||||
provider_handler: ProviderHandler,
|
||||
) -> list[str]:
|
||||
"""Verify which repos the user has access to."""
|
||||
verified_repos: list[str] = []
|
||||
|
||||
for repo_name in potential_repos:
|
||||
try:
|
||||
repository = await provider_handler.verify_repo_provider(repo_name)
|
||||
verified_repos.append(repository.full_name)
|
||||
logger.debug(
|
||||
'[Jira] Repository verification succeeded',
|
||||
extra={'issue_key': issue_key, 'repository': repository.full_name},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
'[Jira] Repository verification failed',
|
||||
extra={
|
||||
'issue_key': issue_key,
|
||||
'repo_name': repo_name,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
|
||||
return verified_repos
|
||||
|
||||
@staticmethod
|
||||
def _select_single_repo(
|
||||
issue_key: str,
|
||||
potential_repos: list[str],
|
||||
verified_repos: list[str],
|
||||
) -> str:
|
||||
"""Select exactly one repo from verified repos.
|
||||
|
||||
Raises:
|
||||
RepositoryNotFoundError: If zero or multiple repos verified.
|
||||
"""
|
||||
if len(verified_repos) == 0:
|
||||
raise RepositoryNotFoundError(
|
||||
f'Could not access any of the mentioned repositories: {", ".join(potential_repos)}. '
|
||||
'Please ensure you have access to the repository and it exists.'
|
||||
)
|
||||
|
||||
if len(verified_repos) > 1:
|
||||
raise RepositoryNotFoundError(
|
||||
f'Multiple repositories found: {", ".join(verified_repos)}. '
|
||||
'Please specify exactly one repository in the issue description or comment.'
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'[Jira] Verified repository access',
|
||||
extra={'issue_key': issue_key, 'repository': verified_repos[0]},
|
||||
)
|
||||
return verified_repos[0]
|
||||
|
||||
@staticmethod
|
||||
async def _infer_repository(
|
||||
payload: JiraWebhookPayload,
|
||||
user_auth: UserAuth,
|
||||
issue_title: str,
|
||||
issue_description: str,
|
||||
) -> str:
|
||||
"""Infer and verify the repository from issue content.
|
||||
|
||||
Raises:
|
||||
RepositoryNotFoundError: If no valid repository can be determined.
|
||||
"""
|
||||
provider_handler = await JiraFactory._create_provider_handler(user_auth)
|
||||
if not provider_handler:
|
||||
raise RepositoryNotFoundError(
|
||||
'No Git provider connected. Please connect a Git provider in OpenHands settings.'
|
||||
)
|
||||
|
||||
potential_repos = JiraFactory._extract_potential_repos(
|
||||
payload.issue_key, issue_title, issue_description, payload.user_msg
|
||||
)
|
||||
|
||||
verified_repos = await JiraFactory._verify_repos(
|
||||
payload.issue_key, potential_repos, provider_handler
|
||||
)
|
||||
|
||||
return JiraFactory._select_single_repo(
|
||||
payload.issue_key, potential_repos, verified_repos
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def create_view(
|
||||
payload: JiraWebhookPayload,
|
||||
workspace: JiraWorkspace,
|
||||
user: JiraUser,
|
||||
user_auth: UserAuth,
|
||||
decrypted_api_key: str,
|
||||
) -> JiraViewInterface:
|
||||
"""Create a Jira view with repository already selected.
|
||||
|
||||
This factory method:
|
||||
1. Creates the view with payload and auth context
|
||||
2. Fetches issue details (needed for repo inference)
|
||||
3. Infers and selects the repository
|
||||
|
||||
If any step fails, an appropriate exception is raised with
|
||||
a user-friendly message.
|
||||
|
||||
Args:
|
||||
payload: Parsed webhook payload
|
||||
workspace: The Jira workspace
|
||||
user: The Jira user
|
||||
user_auth: OpenHands user authentication
|
||||
decrypted_api_key: Decrypted service account API key
|
||||
|
||||
Returns:
|
||||
A JiraViewInterface with selected_repo populated
|
||||
|
||||
Raises:
|
||||
StartingConvoException: If view creation fails
|
||||
RepositoryNotFoundError: If repository cannot be determined
|
||||
"""
|
||||
logger.info(
|
||||
'[Jira] Creating view',
|
||||
extra={
|
||||
'issue_key': payload.issue_key,
|
||||
'event_type': payload.event_type.value,
|
||||
},
|
||||
)
|
||||
|
||||
# Create the view
|
||||
view = JiraNewConversationView(
|
||||
payload=payload,
|
||||
saas_user_auth=user_auth,
|
||||
jira_user=user,
|
||||
jira_workspace=workspace,
|
||||
_decrypted_api_key=decrypted_api_key,
|
||||
)
|
||||
|
||||
# Fetch issue details (needed for repo inference)
|
||||
try:
|
||||
issue_title, issue_description = await view.get_issue_details()
|
||||
except StartingConvoException:
|
||||
raise # Re-raise with original message
|
||||
except Exception as e:
|
||||
raise StartingConvoException(f'Failed to fetch issue details: {str(e)}')
|
||||
|
||||
# Infer and select repository
|
||||
selected_repo = await JiraFactory._infer_repository(
|
||||
payload=payload,
|
||||
user_auth=user_auth,
|
||||
issue_title=issue_title,
|
||||
issue_description=issue_description,
|
||||
)
|
||||
|
||||
view.selected_repo = selected_repo
|
||||
|
||||
logger.info(
|
||||
'[Jira] View created successfully',
|
||||
extra={
|
||||
'issue_key': payload.issue_key,
|
||||
'selected_repo': selected_repo,
|
||||
},
|
||||
)
|
||||
|
||||
return view
|
||||
|
||||
@@ -16,11 +16,6 @@ class Manager(ABC):
|
||||
"Send message to integration from Openhands server"
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def is_job_requested(self, message: Message) -> bool:
|
||||
"Confirm that a job is being requested"
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def start_job(self):
|
||||
"Kick off a job with openhands agent"
|
||||
|
||||
@@ -398,53 +398,42 @@ def infer_repo_from_message(user_msg: str) -> list[str]:
|
||||
"""
|
||||
Extract all repository names in the format 'owner/repo' from various Git provider URLs
|
||||
and direct mentions in text. Supports GitHub, GitLab, and BitBucket.
|
||||
Args:
|
||||
user_msg: Input message that may contain repository references
|
||||
Returns:
|
||||
List of repository names in 'owner/repo' format, empty list if none found
|
||||
"""
|
||||
# Normalize the message by removing extra whitespace and newlines
|
||||
normalized_msg = re.sub(r'\s+', ' ', user_msg.strip())
|
||||
|
||||
# Pattern to match Git URLs from GitHub, GitLab, and BitBucket
|
||||
# Captures: protocol, domain, owner, repo (with optional .git extension)
|
||||
git_url_pattern = r'https?://(?:github\.com|gitlab\.com|bitbucket\.org)/([a-zA-Z0-9_.-]+)/([a-zA-Z0-9_.-]+?)(?:\.git)?(?:[/?#].*?)?(?=\s|$|[^\w.-])'
|
||||
|
||||
# Pattern to match direct owner/repo mentions (e.g., "OpenHands/OpenHands")
|
||||
# Must be surrounded by word boundaries or specific characters to avoid false positives
|
||||
direct_pattern = (
|
||||
r'(?:^|\s|[\[\(\'"])([a-zA-Z0-9_.-]+)/([a-zA-Z0-9_.-]+)(?=\s|$|[\]\)\'",.])'
|
||||
git_url_pattern = (
|
||||
r'https?://(?:github\.com|gitlab\.com|bitbucket\.org)/'
|
||||
r'([a-zA-Z0-9_.-]+)/([a-zA-Z0-9_.-]+?)(?:\.git)?'
|
||||
r'(?:[/?#].*?)?(?=\s|$|[^\w.-])'
|
||||
)
|
||||
|
||||
matches = []
|
||||
# UPDATED: allow {{ owner/repo }} in addition to existing boundaries
|
||||
direct_pattern = (
|
||||
r'(?:^|\s|{{|[\[\(\'":`])' # left boundary
|
||||
r'([a-zA-Z0-9_.-]+)/([a-zA-Z0-9_.-]+)'
|
||||
r'(?=\s|$|}}|[\]\)\'",.:`])' # right boundary
|
||||
)
|
||||
|
||||
# First, find all Git URLs (highest priority)
|
||||
git_matches = re.findall(git_url_pattern, normalized_msg)
|
||||
for owner, repo in git_matches:
|
||||
# Remove .git extension if present
|
||||
matches: list[str] = []
|
||||
|
||||
# Git URLs first (highest priority)
|
||||
for owner, repo in re.findall(git_url_pattern, normalized_msg):
|
||||
repo = re.sub(r'\.git$', '', repo)
|
||||
matches.append(f'{owner}/{repo}')
|
||||
|
||||
# Second, find all direct owner/repo mentions
|
||||
direct_matches = re.findall(direct_pattern, normalized_msg)
|
||||
for owner, repo in direct_matches:
|
||||
# Direct mentions
|
||||
for owner, repo in re.findall(direct_pattern, normalized_msg):
|
||||
full_match = f'{owner}/{repo}'
|
||||
|
||||
# Skip if it looks like a version number, date, or file path
|
||||
if (
|
||||
re.match(r'^\d+\.\d+/\d+\.\d+$', full_match) # version numbers
|
||||
or re.match(r'^\d{1,2}/\d{1,2}$', full_match) # dates
|
||||
or re.match(r'^[A-Z]/[A-Z]$', full_match) # single letters
|
||||
or repo.endswith('.txt')
|
||||
or repo.endswith('.md') # file extensions
|
||||
or repo.endswith('.py')
|
||||
or repo.endswith('.js')
|
||||
or '.' in repo
|
||||
and len(repo.split('.')) > 2
|
||||
): # complex file paths
|
||||
re.match(r'^\d+\.\d+/\d+\.\d+$', full_match)
|
||||
or re.match(r'^\d{1,2}/\d{1,2}$', full_match)
|
||||
or re.match(r'^[A-Z]/[A-Z]$', full_match)
|
||||
or repo.endswith(('.txt', '.md', '.py', '.js'))
|
||||
or ('.' in repo and len(repo.split('.')) > 2)
|
||||
):
|
||||
continue
|
||||
|
||||
# Avoid duplicates from Git URLs already found
|
||||
if full_match not in matches:
|
||||
matches.append(full_match)
|
||||
|
||||
|
||||
@@ -6,10 +6,13 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from integrations.jira.jira_manager import JiraManager
|
||||
from integrations.jira.jira_payload import (
|
||||
JiraEventType,
|
||||
JiraWebhookPayload,
|
||||
)
|
||||
from integrations.jira.jira_view import (
|
||||
JiraNewConversationView,
|
||||
)
|
||||
from integrations.models import JobContext
|
||||
from jinja2 import DictLoader, Environment
|
||||
from storage.jira_conversation import JiraConversation
|
||||
from storage.jira_user import JiraUser
|
||||
@@ -24,7 +27,7 @@ def mock_token_manager():
|
||||
"""Create a mock TokenManager for testing."""
|
||||
token_manager = MagicMock()
|
||||
token_manager.get_user_id_from_user_email = AsyncMock()
|
||||
token_manager.decrypt_text = MagicMock()
|
||||
token_manager.decrypt_text = MagicMock(return_value='decrypted_key')
|
||||
return token_manager
|
||||
|
||||
|
||||
@@ -59,6 +62,7 @@ def sample_jira_workspace():
|
||||
workspace = MagicMock(spec=JiraWorkspace)
|
||||
workspace.id = 1
|
||||
workspace.name = 'test.atlassian.net'
|
||||
workspace.jira_cloud_id = 'cloud-123'
|
||||
workspace.admin_user_id = 'admin_id'
|
||||
workspace.webhook_secret = 'encrypted_secret'
|
||||
workspace.svc_acc_email = 'service@example.com'
|
||||
@@ -74,22 +78,41 @@ def sample_user_auth():
|
||||
user_auth.get_provider_tokens = AsyncMock(return_value={})
|
||||
user_auth.get_access_token = AsyncMock(return_value='test_token')
|
||||
user_auth.get_user_id = AsyncMock(return_value='test_user_id')
|
||||
user_auth.get_secrets = AsyncMock(return_value=None)
|
||||
return user_auth
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_job_context():
|
||||
"""Create a sample JobContext for testing."""
|
||||
return JobContext(
|
||||
def sample_webhook_payload():
|
||||
"""Create a sample JiraWebhookPayload for testing."""
|
||||
return JiraWebhookPayload(
|
||||
event_type=JiraEventType.COMMENT_MENTION,
|
||||
raw_event='comment_created',
|
||||
issue_id='12345',
|
||||
issue_key='TEST-123',
|
||||
user_msg='Fix this bug @openhands',
|
||||
user_email='user@test.com',
|
||||
display_name='Test User',
|
||||
account_id='user123',
|
||||
workspace_name='test.atlassian.net',
|
||||
base_api_url='https://test.atlassian.net',
|
||||
issue_title='Test Issue',
|
||||
issue_description='This is a test issue',
|
||||
comment_body='Fix this bug @openhands',
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_label_webhook_payload():
|
||||
"""Create a sample labeled ticket JiraWebhookPayload for testing."""
|
||||
return JiraWebhookPayload(
|
||||
event_type=JiraEventType.LABELED_TICKET,
|
||||
raw_event='jira:issue_updated',
|
||||
issue_id='12345',
|
||||
issue_key='PROJ-123',
|
||||
user_email='user@company.com',
|
||||
display_name='Test User',
|
||||
account_id='user456',
|
||||
workspace_name='jira.company.com',
|
||||
base_api_url='https://jira.company.com',
|
||||
comment_body='',
|
||||
)
|
||||
|
||||
|
||||
@@ -180,16 +203,17 @@ def jira_conversation():
|
||||
|
||||
@pytest.fixture
|
||||
def new_conversation_view(
|
||||
sample_job_context, sample_user_auth, sample_jira_user, sample_jira_workspace
|
||||
sample_webhook_payload, sample_user_auth, sample_jira_user, sample_jira_workspace
|
||||
):
|
||||
"""JiraNewConversationView instance for testing"""
|
||||
return JiraNewConversationView(
|
||||
job_context=sample_job_context,
|
||||
payload=sample_webhook_payload,
|
||||
saas_user_auth=sample_user_auth,
|
||||
jira_user=sample_jira_user,
|
||||
jira_workspace=sample_jira_workspace,
|
||||
selected_repo='test/repo1',
|
||||
conversation_id='conv-123',
|
||||
_decrypted_api_key='decrypted_key',
|
||||
)
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,29 +2,90 @@
|
||||
Tests for Jira view classes and factory.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from integrations.jira.jira_types import StartingConvoException
|
||||
from integrations.jira.jira_payload import (
|
||||
JiraEventType,
|
||||
JiraPayloadError,
|
||||
JiraPayloadParser,
|
||||
JiraPayloadSkipped,
|
||||
JiraPayloadSuccess,
|
||||
)
|
||||
from integrations.jira.jira_types import RepositoryNotFoundError, StartingConvoException
|
||||
from integrations.jira.jira_view import (
|
||||
JiraFactory,
|
||||
JiraNewConversationView,
|
||||
)
|
||||
from integrations.models import Message, SourceType
|
||||
|
||||
|
||||
class TestJiraNewConversationView:
|
||||
"""Tests for JiraNewConversationView"""
|
||||
|
||||
def test_get_instructions(self, new_conversation_view, mock_jinja_env):
|
||||
"""Test _get_instructions method"""
|
||||
instructions, user_msg = new_conversation_view._get_instructions(mock_jinja_env)
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_issue_details_success(
|
||||
self, new_conversation_view, sample_jira_workspace
|
||||
):
|
||||
"""Test successful issue details retrieval."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
'fields': {'summary': 'Test Issue', 'description': 'Test description'}
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch('httpx.AsyncClient') as mock_client:
|
||||
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
|
||||
return_value=mock_response
|
||||
)
|
||||
|
||||
title, description = await new_conversation_view.get_issue_details()
|
||||
|
||||
assert title == 'Test Issue'
|
||||
assert description == 'Test description'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_issue_details_cached(self, new_conversation_view):
|
||||
"""Test issue details are cached after first call."""
|
||||
new_conversation_view._issue_title = 'Cached Title'
|
||||
new_conversation_view._issue_description = 'Cached Description'
|
||||
|
||||
title, description = await new_conversation_view.get_issue_details()
|
||||
|
||||
assert title == 'Cached Title'
|
||||
assert description == 'Cached Description'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_issue_details_no_title(self, new_conversation_view):
|
||||
"""Test issue details with no title raises error."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
'fields': {'summary': '', 'description': 'Test description'}
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch('httpx.AsyncClient') as mock_client:
|
||||
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
|
||||
return_value=mock_response
|
||||
)
|
||||
|
||||
with pytest.raises(StartingConvoException, match='does not have a title'):
|
||||
await new_conversation_view.get_issue_details()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_instructions(self, new_conversation_view, mock_jinja_env):
|
||||
"""Test _get_instructions method fetches issue details."""
|
||||
new_conversation_view._issue_title = 'Test Issue'
|
||||
new_conversation_view._issue_description = 'This is a test issue'
|
||||
|
||||
instructions, user_msg = await new_conversation_view._get_instructions(
|
||||
mock_jinja_env
|
||||
)
|
||||
|
||||
assert instructions == 'Test Jira instructions template'
|
||||
assert 'TEST-123' in user_msg
|
||||
assert 'Test Issue' in user_msg
|
||||
assert 'Fix this bug @openhands' in user_msg
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('integrations.jira.jira_view.create_new_conversation')
|
||||
@patch('integrations.jira.jira_view.integration_store')
|
||||
async def test_create_or_update_conversation_success(
|
||||
@@ -36,6 +97,8 @@ class TestJiraNewConversationView:
|
||||
mock_agent_loop_info,
|
||||
):
|
||||
"""Test successful conversation creation"""
|
||||
new_conversation_view._issue_title = 'Test Issue'
|
||||
new_conversation_view._issue_description = 'Test description'
|
||||
mock_create_conversation.return_value = mock_agent_loop_info
|
||||
mock_store.create_conversation = AsyncMock()
|
||||
|
||||
@@ -47,6 +110,7 @@ class TestJiraNewConversationView:
|
||||
mock_create_conversation.assert_called_once()
|
||||
mock_store.create_conversation.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_or_update_conversation_no_repo(
|
||||
self, new_conversation_view, mock_jinja_env
|
||||
):
|
||||
@@ -56,18 +120,6 @@ class TestJiraNewConversationView:
|
||||
with pytest.raises(StartingConvoException, match='No repository selected'):
|
||||
await new_conversation_view.create_or_update_conversation(mock_jinja_env)
|
||||
|
||||
@patch('integrations.jira.jira_view.create_new_conversation')
|
||||
async def test_create_or_update_conversation_failure(
|
||||
self, mock_create_conversation, new_conversation_view, mock_jinja_env
|
||||
):
|
||||
"""Test conversation creation failure"""
|
||||
mock_create_conversation.side_effect = Exception('Creation failed')
|
||||
|
||||
with pytest.raises(
|
||||
StartingConvoException, match='Failed to create conversation'
|
||||
):
|
||||
await new_conversation_view.create_or_update_conversation(mock_jinja_env)
|
||||
|
||||
def test_get_response_msg(self, new_conversation_view):
|
||||
"""Test get_response_msg method"""
|
||||
response = new_conversation_view.get_response_msg()
|
||||
@@ -81,469 +133,333 @@ class TestJiraNewConversationView:
|
||||
class TestJiraFactory:
|
||||
"""Tests for JiraFactory"""
|
||||
|
||||
@patch('integrations.jira.jira_view.integration_store')
|
||||
async def test_create_jira_view_from_payload_new_conversation(
|
||||
@pytest.mark.asyncio
|
||||
@patch('integrations.jira.jira_view.JiraFactory._create_provider_handler')
|
||||
@patch('integrations.jira.jira_view.infer_repo_from_message')
|
||||
async def test_create_view_success(
|
||||
self,
|
||||
mock_store,
|
||||
sample_job_context,
|
||||
mock_infer_repos,
|
||||
mock_create_handler,
|
||||
sample_webhook_payload,
|
||||
sample_user_auth,
|
||||
sample_jira_user,
|
||||
sample_jira_workspace,
|
||||
sample_repositories,
|
||||
):
|
||||
"""Test factory creating view with repo selection."""
|
||||
# Setup mock provider handler
|
||||
mock_handler = MagicMock()
|
||||
mock_handler.verify_repo_provider = AsyncMock(
|
||||
return_value=sample_repositories[0]
|
||||
)
|
||||
mock_create_handler.return_value = mock_handler
|
||||
|
||||
# Mock repo inference to return a repo name
|
||||
mock_infer_repos.return_value = ['test/repo1']
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
'fields': {'summary': 'Test Issue', 'description': 'Test description'}
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch('httpx.AsyncClient') as mock_client:
|
||||
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
|
||||
return_value=mock_response
|
||||
)
|
||||
|
||||
view = await JiraFactory.create_view(
|
||||
payload=sample_webhook_payload,
|
||||
workspace=sample_jira_workspace,
|
||||
user=sample_jira_user,
|
||||
user_auth=sample_user_auth,
|
||||
decrypted_api_key='test_api_key',
|
||||
)
|
||||
|
||||
assert isinstance(view, JiraNewConversationView)
|
||||
assert view.selected_repo == 'test/repo1'
|
||||
mock_handler.verify_repo_provider.assert_called_once_with('test/repo1')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('integrations.jira.jira_view.JiraFactory._create_provider_handler')
|
||||
@patch('integrations.jira.jira_view.infer_repo_from_message')
|
||||
async def test_create_view_no_repo_in_text(
|
||||
self,
|
||||
mock_infer_repos,
|
||||
mock_create_handler,
|
||||
sample_webhook_payload,
|
||||
sample_user_auth,
|
||||
sample_jira_user,
|
||||
sample_jira_workspace,
|
||||
):
|
||||
"""Test factory creating new conversation view"""
|
||||
mock_store.get_user_conversations_by_issue_id = AsyncMock(return_value=None)
|
||||
"""Test factory raises error when no repo mentioned in text."""
|
||||
mock_handler = MagicMock()
|
||||
mock_create_handler.return_value = mock_handler
|
||||
|
||||
view = await JiraFactory.create_jira_view_from_payload(
|
||||
sample_job_context,
|
||||
sample_user_auth,
|
||||
sample_jira_user,
|
||||
sample_jira_workspace,
|
||||
# No repos found in text
|
||||
mock_infer_repos.return_value = []
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
'fields': {'summary': 'Test Issue', 'description': 'Test description'}
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch('httpx.AsyncClient') as mock_client:
|
||||
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
|
||||
return_value=mock_response
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
RepositoryNotFoundError, match='Could not determine which repository'
|
||||
):
|
||||
await JiraFactory.create_view(
|
||||
payload=sample_webhook_payload,
|
||||
workspace=sample_jira_workspace,
|
||||
user=sample_jira_user,
|
||||
user_auth=sample_user_auth,
|
||||
decrypted_api_key='test_api_key',
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('integrations.jira.jira_view.JiraFactory._create_provider_handler')
|
||||
@patch('integrations.jira.jira_view.infer_repo_from_message')
|
||||
async def test_create_view_repo_verification_fails(
|
||||
self,
|
||||
mock_infer_repos,
|
||||
mock_create_handler,
|
||||
sample_webhook_payload,
|
||||
sample_user_auth,
|
||||
sample_jira_user,
|
||||
sample_jira_workspace,
|
||||
):
|
||||
"""Test factory raises error when repo verification fails."""
|
||||
mock_handler = MagicMock()
|
||||
mock_handler.verify_repo_provider = AsyncMock(
|
||||
side_effect=Exception('Repository not found')
|
||||
)
|
||||
mock_create_handler.return_value = mock_handler
|
||||
|
||||
# Repos found in text but verification fails
|
||||
mock_infer_repos.return_value = ['test/repo1', 'test/repo2']
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
'fields': {'summary': 'Test Issue', 'description': 'Test description'}
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch('httpx.AsyncClient') as mock_client:
|
||||
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
|
||||
return_value=mock_response
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
RepositoryNotFoundError,
|
||||
match='Could not access any of the mentioned repositories',
|
||||
):
|
||||
await JiraFactory.create_view(
|
||||
payload=sample_webhook_payload,
|
||||
workspace=sample_jira_workspace,
|
||||
user=sample_jira_user,
|
||||
user_auth=sample_user_auth,
|
||||
decrypted_api_key='test_api_key',
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('integrations.jira.jira_view.JiraFactory._create_provider_handler')
|
||||
@patch('integrations.jira.jira_view.infer_repo_from_message')
|
||||
async def test_create_view_multiple_repos_verified(
|
||||
self,
|
||||
mock_infer_repos,
|
||||
mock_create_handler,
|
||||
sample_webhook_payload,
|
||||
sample_user_auth,
|
||||
sample_jira_user,
|
||||
sample_jira_workspace,
|
||||
sample_repositories,
|
||||
):
|
||||
"""Test factory raises error when multiple repos are verified."""
|
||||
mock_handler = MagicMock()
|
||||
# Both repos verify successfully
|
||||
mock_handler.verify_repo_provider = AsyncMock(
|
||||
side_effect=[sample_repositories[0], sample_repositories[1]]
|
||||
)
|
||||
mock_create_handler.return_value = mock_handler
|
||||
|
||||
# Multiple repos found in text
|
||||
mock_infer_repos.return_value = ['test/repo1', 'test/repo2']
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
'fields': {'summary': 'Test Issue', 'description': 'Test description'}
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch('httpx.AsyncClient') as mock_client:
|
||||
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
|
||||
return_value=mock_response
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
RepositoryNotFoundError, match='Multiple repositories found'
|
||||
):
|
||||
await JiraFactory.create_view(
|
||||
payload=sample_webhook_payload,
|
||||
workspace=sample_jira_workspace,
|
||||
user=sample_jira_user,
|
||||
user_auth=sample_user_auth,
|
||||
decrypted_api_key='test_api_key',
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('integrations.jira.jira_view.JiraFactory._create_provider_handler')
|
||||
async def test_create_view_no_provider(
|
||||
self,
|
||||
mock_create_handler,
|
||||
sample_webhook_payload,
|
||||
sample_user_auth,
|
||||
sample_jira_user,
|
||||
sample_jira_workspace,
|
||||
):
|
||||
"""Test factory raises error when no provider is connected."""
|
||||
mock_create_handler.return_value = None
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
'fields': {'summary': 'Test Issue', 'description': 'Test description'}
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch('httpx.AsyncClient') as mock_client:
|
||||
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
|
||||
return_value=mock_response
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
RepositoryNotFoundError, match='No Git provider connected'
|
||||
):
|
||||
await JiraFactory.create_view(
|
||||
payload=sample_webhook_payload,
|
||||
workspace=sample_jira_workspace,
|
||||
user=sample_jira_user,
|
||||
user_auth=sample_user_auth,
|
||||
decrypted_api_key='test_api_key',
|
||||
)
|
||||
|
||||
|
||||
class TestJiraPayloadParser:
|
||||
"""Tests for JiraPayloadParser"""
|
||||
|
||||
@pytest.fixture
|
||||
def parser(self):
|
||||
"""Create a parser for testing."""
|
||||
return JiraPayloadParser(oh_label='openhands', inline_oh_label='@openhands')
|
||||
|
||||
def test_parse_label_event_success(
|
||||
self, parser, sample_issue_update_webhook_payload
|
||||
):
|
||||
"""Test parsing label event."""
|
||||
result = parser.parse(sample_issue_update_webhook_payload)
|
||||
|
||||
assert isinstance(result, JiraPayloadSuccess)
|
||||
assert result.payload.event_type == JiraEventType.LABELED_TICKET
|
||||
assert result.payload.issue_key == 'PROJ-123'
|
||||
|
||||
def test_parse_comment_event_success(self, parser, sample_comment_webhook_payload):
|
||||
"""Test parsing comment event."""
|
||||
result = parser.parse(sample_comment_webhook_payload)
|
||||
|
||||
assert isinstance(result, JiraPayloadSuccess)
|
||||
assert result.payload.event_type == JiraEventType.COMMENT_MENTION
|
||||
assert result.payload.issue_key == 'TEST-123'
|
||||
assert '@openhands' in result.payload.comment_body
|
||||
|
||||
def test_parse_unknown_event_skipped(self, parser):
|
||||
"""Test unknown event is skipped."""
|
||||
payload = {'webhookEvent': 'unknown_event'}
|
||||
result = parser.parse(payload)
|
||||
|
||||
assert isinstance(result, JiraPayloadSkipped)
|
||||
assert 'Unhandled webhook event type' in result.skip_reason
|
||||
|
||||
def test_parse_label_event_wrong_label_skipped(self, parser):
|
||||
"""Test label event without OH label is skipped."""
|
||||
payload = {
|
||||
'webhookEvent': 'jira:issue_updated',
|
||||
'changelog': {'items': [{'field': 'labels', 'toString': 'other-label'}]},
|
||||
}
|
||||
result = parser.parse(payload)
|
||||
|
||||
assert isinstance(result, JiraPayloadSkipped)
|
||||
assert 'does not contain' in result.skip_reason
|
||||
|
||||
def test_parse_comment_event_no_mention_skipped(self, parser):
|
||||
"""Test comment without mention is skipped."""
|
||||
payload = {
|
||||
'webhookEvent': 'comment_created',
|
||||
'comment': {
|
||||
'body': 'Regular comment',
|
||||
'author': {'emailAddress': 'test@test.com'},
|
||||
},
|
||||
}
|
||||
result = parser.parse(payload)
|
||||
|
||||
assert isinstance(result, JiraPayloadSkipped)
|
||||
assert 'does not mention' in result.skip_reason
|
||||
|
||||
def test_parse_missing_fields_error(self, parser):
|
||||
"""Test missing required fields returns error."""
|
||||
payload = {
|
||||
'webhookEvent': 'jira:issue_updated',
|
||||
'changelog': {'items': [{'field': 'labels', 'toString': 'openhands'}]},
|
||||
'issue': {'id': '123'}, # Missing key
|
||||
'user': {'emailAddress': 'test@test.com'}, # Missing other fields
|
||||
}
|
||||
result = parser.parse(payload)
|
||||
|
||||
assert isinstance(result, JiraPayloadError)
|
||||
assert 'Missing required fields' in result.error
|
||||
|
||||
|
||||
class TestJiraPayloadParserStagingLabels:
|
||||
"""Tests for JiraPayloadParser with staging labels."""
|
||||
|
||||
@pytest.fixture
|
||||
def staging_parser(self):
|
||||
"""Create a parser with staging labels."""
|
||||
return JiraPayloadParser(
|
||||
oh_label='openhands-exp', inline_oh_label='@openhands-exp'
|
||||
)
|
||||
|
||||
assert isinstance(view, JiraNewConversationView)
|
||||
assert view.conversation_id == ''
|
||||
def test_parse_staging_label(self, staging_parser):
|
||||
"""Test parsing with staging label."""
|
||||
payload = {
|
||||
'webhookEvent': 'jira:issue_updated',
|
||||
'changelog': {'items': [{'field': 'labels', 'toString': 'openhands-exp'}]},
|
||||
'issue': {
|
||||
'id': '123',
|
||||
'key': 'TEST-1',
|
||||
'self': 'https://test.atlassian.net/rest/api/2/issue/123',
|
||||
},
|
||||
'user': {
|
||||
'emailAddress': 'test@test.com',
|
||||
'displayName': 'Test',
|
||||
'accountId': 'acc123',
|
||||
'self': 'https://test.atlassian.net/rest/api/2/user',
|
||||
},
|
||||
}
|
||||
result = staging_parser.parse(payload)
|
||||
|
||||
async def test_create_jira_view_from_payload_no_user(
|
||||
self, sample_job_context, sample_user_auth, sample_jira_workspace
|
||||
):
|
||||
"""Test factory with no Jira user"""
|
||||
with pytest.raises(StartingConvoException, match='User not authenticated'):
|
||||
await JiraFactory.create_jira_view_from_payload(
|
||||
sample_job_context,
|
||||
sample_user_auth,
|
||||
None,
|
||||
sample_jira_workspace, # type: ignore
|
||||
)
|
||||
assert isinstance(result, JiraPayloadSuccess)
|
||||
assert result.payload.event_type == JiraEventType.LABELED_TICKET
|
||||
|
||||
async def test_create_jira_view_from_payload_no_auth(
|
||||
self, sample_job_context, sample_jira_user, sample_jira_workspace
|
||||
):
|
||||
"""Test factory with no SaaS auth"""
|
||||
with pytest.raises(StartingConvoException, match='User not authenticated'):
|
||||
await JiraFactory.create_jira_view_from_payload(
|
||||
sample_job_context,
|
||||
None,
|
||||
sample_jira_user,
|
||||
sample_jira_workspace, # type: ignore
|
||||
)
|
||||
def test_parse_prod_label_in_staging_skipped(self, staging_parser):
|
||||
"""Test prod label is skipped in staging environment."""
|
||||
payload = {
|
||||
'webhookEvent': 'jira:issue_updated',
|
||||
'changelog': {'items': [{'field': 'labels', 'toString': 'openhands'}]},
|
||||
}
|
||||
result = staging_parser.parse(payload)
|
||||
|
||||
async def test_create_jira_view_from_payload_no_workspace(
|
||||
self, sample_job_context, sample_user_auth, sample_jira_user
|
||||
):
|
||||
"""Test factory with no workspace"""
|
||||
with pytest.raises(StartingConvoException, match='User not authenticated'):
|
||||
await JiraFactory.create_jira_view_from_payload(
|
||||
sample_job_context,
|
||||
sample_user_auth,
|
||||
sample_jira_user,
|
||||
None, # type: ignore
|
||||
)
|
||||
|
||||
|
||||
class TestJiraViewEdgeCases:
|
||||
"""Tests for edge cases and error scenarios"""
|
||||
|
||||
@patch('integrations.jira.jira_view.create_new_conversation')
|
||||
@patch('integrations.jira.jira_view.integration_store')
|
||||
async def test_conversation_creation_with_no_user_secrets(
|
||||
self,
|
||||
mock_store,
|
||||
mock_create_conversation,
|
||||
new_conversation_view,
|
||||
mock_jinja_env,
|
||||
mock_agent_loop_info,
|
||||
):
|
||||
"""Test conversation creation when user has no secrets"""
|
||||
new_conversation_view.saas_user_auth.get_secrets.return_value = None
|
||||
mock_create_conversation.return_value = mock_agent_loop_info
|
||||
mock_store.create_conversation = AsyncMock()
|
||||
|
||||
result = await new_conversation_view.create_or_update_conversation(
|
||||
mock_jinja_env
|
||||
)
|
||||
|
||||
assert result == 'conv-123'
|
||||
# Verify create_new_conversation was called with custom_secrets=None
|
||||
call_kwargs = mock_create_conversation.call_args[1]
|
||||
assert call_kwargs['custom_secrets'] is None
|
||||
|
||||
@patch('integrations.jira.jira_view.create_new_conversation')
|
||||
@patch('integrations.jira.jira_view.integration_store')
|
||||
async def test_conversation_creation_store_failure(
|
||||
self,
|
||||
mock_store,
|
||||
mock_create_conversation,
|
||||
new_conversation_view,
|
||||
mock_jinja_env,
|
||||
mock_agent_loop_info,
|
||||
):
|
||||
"""Test conversation creation when store creation fails"""
|
||||
mock_create_conversation.return_value = mock_agent_loop_info
|
||||
mock_store.create_conversation = AsyncMock(side_effect=Exception('Store error'))
|
||||
|
||||
with pytest.raises(
|
||||
StartingConvoException, match='Failed to create conversation'
|
||||
):
|
||||
await new_conversation_view.create_or_update_conversation(mock_jinja_env)
|
||||
|
||||
def test_new_conversation_view_attributes(self, new_conversation_view):
|
||||
"""Test new conversation view attribute access"""
|
||||
assert new_conversation_view.job_context.issue_key == 'TEST-123'
|
||||
assert new_conversation_view.selected_repo == 'test/repo1'
|
||||
assert new_conversation_view.conversation_id == 'conv-123'
|
||||
|
||||
|
||||
class TestJiraFactoryIsLabeledTicket:
|
||||
"""Parameterized tests for JiraFactory.is_labeled_ticket method."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'payload,expected',
|
||||
[
|
||||
pytest.param(
|
||||
{
|
||||
'webhookEvent': 'jira:issue_updated',
|
||||
'changelog': {
|
||||
'items': [{'field': 'labels', 'toString': 'openhands'}]
|
||||
},
|
||||
},
|
||||
True,
|
||||
id='issue_updated_with_openhands_label',
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
'webhookEvent': 'jira:issue_updated',
|
||||
'changelog': {
|
||||
'items': [
|
||||
{'field': 'labels', 'toString': 'bug'},
|
||||
{'field': 'labels', 'toString': 'openhands'},
|
||||
]
|
||||
},
|
||||
},
|
||||
True,
|
||||
id='issue_updated_with_multiple_labels_including_openhands',
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
'webhookEvent': 'jira:issue_updated',
|
||||
'changelog': {
|
||||
'items': [{'field': 'labels', 'toString': 'bug,urgent'}]
|
||||
},
|
||||
},
|
||||
False,
|
||||
id='issue_updated_without_openhands_label',
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
'webhookEvent': 'jira:issue_updated',
|
||||
'changelog': {'items': []},
|
||||
},
|
||||
False,
|
||||
id='issue_updated_with_empty_changelog_items',
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
'webhookEvent': 'jira:issue_updated',
|
||||
'changelog': {},
|
||||
},
|
||||
False,
|
||||
id='issue_updated_with_empty_changelog',
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
'webhookEvent': 'jira:issue_updated',
|
||||
},
|
||||
False,
|
||||
id='issue_updated_without_changelog',
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
'webhookEvent': 'comment_created',
|
||||
'changelog': {
|
||||
'items': [{'field': 'labels', 'toString': 'openhands'}]
|
||||
},
|
||||
},
|
||||
False,
|
||||
id='comment_created_event_with_label',
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
'webhookEvent': 'issue_deleted',
|
||||
'changelog': {
|
||||
'items': [{'field': 'labels', 'toString': 'openhands'}]
|
||||
},
|
||||
},
|
||||
False,
|
||||
id='unsupported_event_type',
|
||||
),
|
||||
pytest.param(
|
||||
{},
|
||||
False,
|
||||
id='empty_payload',
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
'webhookEvent': 'jira:issue_updated',
|
||||
'changelog': {
|
||||
'items': [{'field': 'status', 'toString': 'In Progress'}]
|
||||
},
|
||||
},
|
||||
False,
|
||||
id='issue_updated_with_non_label_field',
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
'webhookEvent': 'jira:issue_updated',
|
||||
'changelog': {
|
||||
'items': [{'field': 'labels', 'fromString': 'openhands'}]
|
||||
},
|
||||
},
|
||||
False,
|
||||
id='issue_updated_with_fromString_instead_of_toString',
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
'webhookEvent': 'jira:issue_updated',
|
||||
'changelog': {
|
||||
'items': [
|
||||
{'field': 'labels', 'toString': 'not-openhands'},
|
||||
{'field': 'priority', 'toString': 'High'},
|
||||
]
|
||||
},
|
||||
},
|
||||
False,
|
||||
id='issue_updated_with_mixed_fields_no_openhands',
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_is_labeled_ticket(self, payload, expected):
|
||||
"""Test is_labeled_ticket with various payloads."""
|
||||
with patch('integrations.jira.jira_view.OH_LABEL', 'openhands'):
|
||||
message = Message(source=SourceType.JIRA, message={'payload': payload})
|
||||
result = JiraFactory.is_labeled_ticket(message)
|
||||
assert result == expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'payload,expected',
|
||||
[
|
||||
pytest.param(
|
||||
{
|
||||
'webhookEvent': 'jira:issue_updated',
|
||||
'changelog': {
|
||||
'items': [{'field': 'labels', 'toString': 'openhands-exp'}]
|
||||
},
|
||||
},
|
||||
True,
|
||||
id='issue_updated_with_staging_label',
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
'webhookEvent': 'jira:issue_updated',
|
||||
'changelog': {
|
||||
'items': [{'field': 'labels', 'toString': 'openhands'}]
|
||||
},
|
||||
},
|
||||
False,
|
||||
id='issue_updated_with_prod_label_in_staging_env',
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_is_labeled_ticket_staging_labels(self, payload, expected):
|
||||
"""Test is_labeled_ticket with staging environment labels."""
|
||||
with patch('integrations.jira.jira_view.OH_LABEL', 'openhands-exp'):
|
||||
message = Message(source=SourceType.JIRA, message={'payload': payload})
|
||||
result = JiraFactory.is_labeled_ticket(message)
|
||||
assert result == expected
|
||||
|
||||
|
||||
class TestJiraFactoryIsTicketComment:
|
||||
"""Parameterized tests for JiraFactory.is_ticket_comment method."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'payload,expected',
|
||||
[
|
||||
pytest.param(
|
||||
{
|
||||
'webhookEvent': 'comment_created',
|
||||
'comment': {'body': 'Please fix this @openhands'},
|
||||
},
|
||||
True,
|
||||
id='comment_with_openhands_mention',
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
'webhookEvent': 'comment_created',
|
||||
'comment': {'body': '@openhands please help'},
|
||||
},
|
||||
True,
|
||||
id='comment_starting_with_openhands_mention',
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
'webhookEvent': 'comment_created',
|
||||
'comment': {'body': 'Hello @openhands!'},
|
||||
},
|
||||
True,
|
||||
id='comment_with_openhands_mention_and_punctuation',
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
'webhookEvent': 'comment_created',
|
||||
'comment': {'body': '(@openhands)'},
|
||||
},
|
||||
True,
|
||||
id='comment_with_openhands_in_parentheses',
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
'webhookEvent': 'comment_created',
|
||||
'comment': {'body': 'Hey @OpenHands can you help?'},
|
||||
},
|
||||
True,
|
||||
id='comment_with_case_insensitive_mention',
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
'webhookEvent': 'comment_created',
|
||||
'comment': {'body': 'Hey @OPENHANDS!'},
|
||||
},
|
||||
True,
|
||||
id='comment_with_uppercase_mention',
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
'webhookEvent': 'comment_created',
|
||||
'comment': {'body': 'Regular comment without mention'},
|
||||
},
|
||||
False,
|
||||
id='comment_without_mention',
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
'webhookEvent': 'comment_created',
|
||||
'comment': {'body': 'Hello @openhands-agent!'},
|
||||
},
|
||||
False,
|
||||
id='comment_with_openhands_as_prefix',
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
'webhookEvent': 'comment_created',
|
||||
'comment': {'body': 'user@openhands.com'},
|
||||
},
|
||||
False,
|
||||
id='comment_with_openhands_in_email',
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
'webhookEvent': 'comment_created',
|
||||
'comment': {'body': ''},
|
||||
},
|
||||
False,
|
||||
id='comment_with_empty_body',
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
'webhookEvent': 'comment_created',
|
||||
'comment': {},
|
||||
},
|
||||
False,
|
||||
id='comment_without_body',
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
'webhookEvent': 'comment_created',
|
||||
},
|
||||
False,
|
||||
id='comment_created_without_comment_data',
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
'webhookEvent': 'jira:issue_updated',
|
||||
'comment': {'body': 'Please fix this @openhands'},
|
||||
},
|
||||
False,
|
||||
id='issue_updated_event_with_mention',
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
'webhookEvent': 'issue_deleted',
|
||||
'comment': {'body': '@openhands'},
|
||||
},
|
||||
False,
|
||||
id='unsupported_event_type_with_mention',
|
||||
),
|
||||
pytest.param(
|
||||
{},
|
||||
False,
|
||||
id='empty_payload',
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
'webhookEvent': 'comment_created',
|
||||
'comment': {'body': 'Multiple @openhands @openhands mentions'},
|
||||
},
|
||||
True,
|
||||
id='comment_with_multiple_mentions',
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_is_ticket_comment(self, payload, expected):
|
||||
"""Test is_ticket_comment with various payloads."""
|
||||
with patch('integrations.jira.jira_view.INLINE_OH_LABEL', '@openhands'), patch(
|
||||
'integrations.jira.jira_view.has_exact_mention'
|
||||
) as mock_has_exact_mention:
|
||||
from integrations.utils import has_exact_mention
|
||||
|
||||
mock_has_exact_mention.side_effect = (
|
||||
lambda text, mention: has_exact_mention(text, mention)
|
||||
)
|
||||
|
||||
message = Message(source=SourceType.JIRA, message={'payload': payload})
|
||||
result = JiraFactory.is_ticket_comment(message)
|
||||
assert result == expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'payload,expected',
|
||||
[
|
||||
pytest.param(
|
||||
{
|
||||
'webhookEvent': 'comment_created',
|
||||
'comment': {'body': 'Please fix this @openhands-exp'},
|
||||
},
|
||||
True,
|
||||
id='comment_with_staging_mention',
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
'webhookEvent': 'comment_created',
|
||||
'comment': {'body': '@openhands-exp please help'},
|
||||
},
|
||||
True,
|
||||
id='comment_starting_with_staging_mention',
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
'webhookEvent': 'comment_created',
|
||||
'comment': {'body': 'Please fix this @openhands'},
|
||||
},
|
||||
False,
|
||||
id='comment_with_prod_mention_in_staging_env',
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_is_ticket_comment_staging_labels(self, payload, expected):
|
||||
"""Test is_ticket_comment with staging environment labels."""
|
||||
with patch(
|
||||
'integrations.jira.jira_view.INLINE_OH_LABEL', '@openhands-exp'
|
||||
), patch(
|
||||
'integrations.jira.jira_view.has_exact_mention'
|
||||
) as mock_has_exact_mention:
|
||||
from integrations.utils import has_exact_mention
|
||||
|
||||
mock_has_exact_mention.side_effect = (
|
||||
lambda text, mention: has_exact_mention(text, mention)
|
||||
)
|
||||
|
||||
message = Message(source=SourceType.JIRA, message={'payload': payload})
|
||||
result = JiraFactory.is_ticket_comment(message)
|
||||
assert result == expected
|
||||
assert isinstance(result, JiraPayloadSkipped)
|
||||
|
||||
@@ -149,6 +149,18 @@ def test_infer_repo_from_message():
|
||||
('https://github.com/My-User/My-Repo.git', ['My-User/My-Repo']),
|
||||
('Check the my.user/my.repo repository', ['my.user/my.repo']),
|
||||
('repos: user_1/repo-1 and user.2/repo_2', ['user_1/repo-1', 'user.2/repo_2']),
|
||||
# Backtick-wrapped repo mentions (common in Slack/Discord messages)
|
||||
(
|
||||
'@openhands-exp just echo hello world in `OpenHands/OpenHands-CLI` repository',
|
||||
['OpenHands/OpenHands-CLI'],
|
||||
),
|
||||
(
|
||||
'@openhands-exp echo hello world with {{OpenHands/OpenHands-CLI}}',
|
||||
['OpenHands/OpenHands-CLI'],
|
||||
),
|
||||
('Deploy the `test/project` repo', ['test/project']),
|
||||
# Colon-wrapped repo mentions
|
||||
('Check the :owner/repo: here', ['owner/repo']),
|
||||
# Large number of repositories
|
||||
('Repos: a/b, c/d, e/f, g/h, i/j', ['a/b', 'c/d', 'e/f', 'g/h', 'i/j']),
|
||||
# Mixed with false positives that should be filtered
|
||||
|
||||
Reference in New Issue
Block a user